@dsh-gis/dsh-video-studio-skill 0.9.1 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +473 -0
  2. package/README.md +263 -327
  3. package/THIRD_PARTY_NOTICES.md +2 -2
  4. package/package.json +9 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +31 -42
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1216 -1123
  9. package/skills/video-studio/assets/design_fixture.json +141 -0
  10. package/skills/video-studio/assets/film.json +2 -4
  11. package/skills/video-studio/assets/golden_frames.json +13 -10
  12. package/skills/video-studio/assets/scenes.py +153 -65
  13. package/skills/video-studio/assets/voice_loudness.json +16 -0
  14. package/skills/video-studio/references/appearance.md +123 -0
  15. package/skills/video-studio/references/audio-variation.md +74 -93
  16. package/skills/video-studio/references/audio.md +64 -66
  17. package/skills/video-studio/references/change-impact.md +5 -5
  18. package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +16 -18
  19. package/skills/video-studio/references/frame-qa.md +48 -16
  20. package/skills/video-studio/references/pipeline.md +4 -4
  21. package/skills/video-studio/references/pitfalls.md +22 -3
  22. package/skills/video-studio/references/proposal.md +160 -0
  23. package/skills/video-studio/references/quality-gates.md +11 -11
  24. package/skills/video-studio/references/scene-authoring.md +2 -2
  25. package/skills/video-studio/references/scene-grammar.md +40 -42
  26. package/skills/video-studio/references/sfx-design.md +8 -6
  27. package/skills/video-studio/references/web-engine.md +8 -8
  28. package/skills/video-studio/references/word-timeline.md +9 -9
  29. package/skills/video-studio/requirements.txt +9 -13
  30. package/skills/video-studio/scripts/designkit.py +2115 -0
  31. package/skills/video-studio/scripts/filmcheck.py +22 -48
  32. package/skills/video-studio/scripts/filmkit.py +2980 -2904
  33. package/skills/video-studio/scripts/gates.py +4 -4
  34. package/skills/video-studio/scripts/grammar.py +73 -13
  35. package/skills/video-studio/scripts/musicgen.py +2042 -0
  36. package/skills/video-studio/scripts/preflight.py +14 -7
  37. package/skills/video-studio/scripts/previewkit.py +848 -0
  38. package/skills/video-studio/scripts/propose.py +825 -0
  39. package/skills/video-studio/scripts/qa_frames.py +73 -39
  40. package/skills/video-studio/scripts/qaasr.py +26 -7
  41. package/skills/video-studio/scripts/qavmaf.py +89 -23
  42. package/skills/video-studio/scripts/render.py +545 -231
  43. package/skills/video-studio/scripts/scorekit.py +55 -1755
  44. package/skills/video-studio/scripts/selfcheck.py +815 -215
  45. package/skills/video-studio/scripts/sfxkit.py +27 -26
  46. package/skills/video-studio/scripts/stagewatch.py +24 -8
  47. package/skills/video-studio/scripts/stylekit.py +207 -888
  48. package/skills/video-studio/scripts/voicekit.py +508 -387
  49. package/skills/video-studio/scripts/webengine.py +71 -72
  50. package/tools/audio_similarity.py +64 -37
  51. package/tools/ci_check.py +20 -14
  52. package/tools/estimate.py +34 -29
  53. package/tools/golden_frames.py +72 -28
  54. package/tools/silhouette_qa.py +520 -168
  55. package/docs/demo-pil-sheet.png +0 -0
  56. package/docs/styles/demo-results.json +0 -76
  57. package/docs/styles/sheet-8-styles.png +0 -0
  58. package/docs/styles/sheet-ai.png +0 -0
  59. package/docs/styles/sheet-before-after.png +0 -0
  60. package/docs/styles/sheet-craft.png +0 -0
  61. package/docs/styles/sheet-night.png +0 -0
  62. package/skills/video-studio/assets/score_demo.json +0 -307
  63. package/skills/video-studio/references/style-packs.md +0 -283
@@ -1,27 +1,27 @@
1
1
  # -*- coding: utf-8 -*-
2
- """webengine.py — 浏览器逐帧渲染引擎(v0.8.0)。
2
+ """webengine.py — 浏览器逐帧渲染引擎。
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):
8
+ **跟随风格包**。公开入口都有一组可选参数(style / width / height / fps):
9
9
  style stylekit 的 style plan(dict,含 "theme")或裸主题 dict;None = 不注入
10
10
  width/height 画布尺寸,只接受 16:9 横屏偶数尺寸(竖屏 / 4:3 / 奇数直接 raise)
11
11
  fps 写进注入 payload 的帧率;None = 不约束(页面自己的 window.FPS 为准)
12
12
  注入方式:add_init_script 在**页面加载前**写 window.__STYLE__(主题色板 / 版式 / 字体栈 /
13
- 画布 w·h·fps)。assets/web/engine.js 与 film.js 优先读它,取不到就退回 v0.7 深蓝橙常量。
13
+ 画布 w·h·fps)。assets/web/engine.js 与 film.js 优先读它,取不到就退回深蓝橙常量。
14
14
 
15
15
  三条硬约束(改这块必须先读):
16
16
  1. **style=None 时一个字节都不注入**:open_page 连 add_init_script 都不调,页面里不存在
17
- window.__STYLE__,渲染像素与 v0.7 逐字节一致(自检里有「无 style 页面无 __STYLE__」断言,
18
- 外部还有拿 git HEAD 老版本渲同一帧比 SHA-256 的证据)。
17
+ window.__STYLE__,渲染像素与默认外观逐字节一致(自检里有「无 style 页面无 __STYLE__」断言,
18
+ 外部还有逐帧 SHA-256 对照)。
19
19
  2. 注入内容只来自入参:不读环境变量/时间/随机数,JSON 用 sort_keys 序列化,同输入同输出。
20
20
  3. 尺寸与帧率 fail-closed:不符合 16:9 横屏偶数就 raise,绝不静默缩放或改帧率
21
21
  (静默改帧率会让分块帧号整体错位,成片音画不同步且门禁看不出来)。
22
22
 
23
23
  算法改编自 ai-history-film(MIT)video/render.py(逐帧 seek / 分块并行 / 崩溃重开 / 断点续跑),
24
- 代码按本项目 v0.3.0 契约重写;拼接复用 filmkit.concat_segments(绝对路径,v0.2 修过的坑)。
24
+ 代码按本项目契约重写;拼接复用 filmkit.concat_segments(必须传绝对路径,别改成相对路径)。
25
25
 
26
26
  设计要点:
27
27
  1. 逐帧渲染是无状态的:崩溃后重开浏览器从同一帧继续即可,不需要回滚。
@@ -38,13 +38,13 @@ CLI:
38
38
  python webengine.py full <html> --out x.mp4 [--workers 2] [--resume]
39
39
  python webengine.py textqa <html> [--from 0 --to 12 --step 0.2]
40
40
  python webengine.py selftest # 等价于 --selftest
41
- # 以上命令都可加风格参数(缺省 = 旧行为):
42
- # --style magazine | --style work/style.json | --style '{"theme":{...}}'
41
+ # 以上命令都可加风格参数(缺省 = 不注入):
42
+ # --style work/style.json | --style '{"theme":{...}}'
43
43
  # --width 1920 --height 1080 [--fps 30]
44
44
  """
45
45
  from __future__ import annotations
46
46
 
47
- __version__ = "0.8.0"
47
+ __version__ = "1.0.4"
48
48
 
49
49
  import argparse
50
50
  import asyncio
@@ -87,7 +87,7 @@ BROWSER_ARGS = [
87
87
  "--disable-renderer-backgrounding",
88
88
  "--disable-backgrounding-occluded-windows",
89
89
  ]
90
- # 确定性模式(v0.4.0):关掉 GPU 光栅,2D canvas 全部走软件光栅。
90
+ # 确定性模式:关掉 GPU 光栅,2D canvas 全部走软件光栅。
91
91
  # 为什么需要:默认参数下**同一页面会话内**截图 byte 完全一致,但**跨浏览器会话**
92
92
  # (崩溃重开 / 多 worker / 续跑重渲)不完全一致——GPU 抗锯齿光栅化会有极小的非确定性。
93
93
  # 实测(assets/web 模板,f=179/180,8 个独立会话):
@@ -132,10 +132,10 @@ _K = _load_filmkit()
132
132
  FFMPEG = _K.FFMPEG
133
133
 
134
134
 
135
- # ---------------------------------------------------------------- 风格注入(v0.8.0 契约)
135
+ # ---------------------------------------------------------------- 风格注入(契约)
136
136
  # style 的两种合法写法:
137
137
  # ① stylekit 的 style plan(dict,至少含 "theme" 字典;render.py --stage style 落的 work/style.json 就是它)
138
- # ② 裸主题字典(stylekit.THEMES[...] / stylekit.theme_of(id),扁平字段,颜色是 (r,g,b) 元组)
138
+ # ② 裸主题字典(扁平字段,颜色是 (r,g,b) 元组;设计的 theme 就是这种形状)
139
139
  # 归一化后是**纯数据** payload,经 add_init_script 写成 window.__STYLE__。
140
140
  # 这里不做任何"猜":缺键 / 颜色非法 / 字体族未知一律 raise,不做静默回落(回落等于换个画风还不告诉人)。
141
141
  STYLE_VERSION = 1
@@ -149,7 +149,7 @@ FONT_STACKS = {
149
149
  "round": 'DengXian, "Microsoft YaHei", sans-serif',
150
150
  }
151
151
 
152
- # 主题里必须齐全的色板键(stylekit 的 8 套主题全都有;少一个就说明传错东西了)
152
+ # 主题里必须齐全的色板键(designkit 构造的主题都有;少一个就说明传错东西了)
153
153
  THEME_COLOR_KEYS = ("bg", "bg2", "surface", "ink", "muted", "dim",
154
154
  "accent", "accent2", "good", "bad", "rule")
155
155
 
@@ -243,7 +243,7 @@ def _style_theme_dict(style):
243
243
  "legacy": None, "layout_default": None, "chapter_layout": None}
244
244
  raise ValueError(
245
245
  "style 既不是 style plan(没有 \"theme\" 字典)也不是裸主题 dict(没有 bg / ink)。"
246
- "可用写法:stylekit 的 plan(work/style.json)或 stylekit.THEMES[...]。收到键:%s"
246
+ "可用写法:已解析的计划(work/style.json)或裸主题 dict。收到键:%s"
247
247
  % (sorted(style)[:12],))
248
248
 
249
249
 
@@ -467,10 +467,10 @@ def _format_contract(contract: dict) -> str:
467
467
  async def page_number(page, key: str, *, default=None) -> float:
468
468
  """读页面上的数值契约(window.DURATION / window.FPS),缺了或不是正数就给可操作报错。
469
469
 
470
- v0.5 以前直接 float(await page.evaluate("window.DURATION")):页面忘了导出 DURATION
470
+ 直接 float(await page.evaluate("window.DURATION")) 不够:页面忘了导出 DURATION
471
471
  时得到的是 None,报错只剩一句 "float() argument must be ... 'NoneType'",看不出
472
472
  是页面契约缺字段(实测 page_meta / export_cues 都会这样)。
473
- default 传 None 表示必填(DURATION);传数字表示缺省值(FPS 历史口径是 30)。
473
+ default 传 None 表示必填(DURATION);传数字表示缺省值(FPS 缺省口径是 30)。
474
474
  """
475
475
  raw = await page.evaluate("k => window[k]", key)
476
476
  if raw is None:
@@ -496,15 +496,15 @@ async def open_page(pw, url, width=W, height=H, log=False, deterministic=None,
496
496
  浏览器优先 Microsoft Edge(channel="msedge"),启动失败回落 Playwright 自带的 Chromium;
497
497
  视口按 width x height(devicePixelRatio=1),截图与画布 1:1 像素。
498
498
 
499
- v0.4.0 起多了两道**可操作**的契约检查(以前缺 window.ready / renderFrame 时只会
500
- 抛 Playwright 的裸超时异常,看不出是页面脚本挂了还是没调 FK.boot()):
499
+ 两道**可操作**的契约检查(否则缺 window.ready / renderFrame 时只会抛 Playwright 的
500
+ 裸超时异常,看不出是页面脚本挂了还是没调 FK.boot()):
501
501
  1. 等到 window.ready === true 超时 → 报错里带上页面异常/控制台错误和契约快照;
502
- 2. window.renderFrame 不是函数 → 直接报错(不再逐帧重试 5 次才发现)。
502
+ 2. window.renderFrame 不是函数 → 直接报错(不要靠逐帧重试 5 次才发现)。
503
503
  确定性:deterministic=True(或环境变量 WEBENGINE_DETERMINISTIC=1)走软件光栅,
504
504
  跨浏览器会话 byte 级可复现;缺省走 GPU,快约 22%。
505
505
 
506
- v0.8.0 风格:style 非 None 时在 page.goto **之前** add_init_script 注入 window.__STYLE__;
507
- style=None 时**不调用 add_init_script**,页面里不存在 __STYLE__(旧行为逐字节不变)。
506
+ 风格注入:style 非 None 时在 page.goto **之前** add_init_script 注入 window.__STYLE__;
507
+ style=None 时**不调用 add_init_script**,页面里不存在 __STYLE__(默认外观逐字节不变)。
508
508
  width/height 一律先过 _validate_canvas(16:9 横屏偶数,否则 raise);fps 只用于写进
509
509
  payload(None = 不让 payload 约束帧率,页面 window.FPS 才是真值)。
510
510
  """
@@ -603,7 +603,7 @@ async def open_page(pw, url, width=W, height=H, log=False, deterministic=None,
603
603
  if not got.get("hasStyle") or not got.get("applied"):
604
604
  raise RuntimeError(
605
605
  "页面 %s 的 engine.js 不认 window.__STYLE__(FK.style 为空;注入后画风不会变)。"
606
- "风格注入要 engine.js v0.8.0 起才认;把新版 assets/web/ 一起拷过去,"
606
+ "风格注入要新版 engine.js 才认;把新版 assets/web/ 一起拷过去,"
607
607
  "或确认页面脚本顺序是 engine.js → film.js → qa_text.js。" % url)
608
608
  if got.get("w") != want_w or got.get("h") != want_h:
609
609
  raise RuntimeError(
@@ -617,7 +617,7 @@ async def open_page(pw, url, width=W, height=H, log=False, deterministic=None,
617
617
  "帧率必须来自同一份 timeline,不能靠 style 猜。"
618
618
  % (page_fps, payload["canvas"]["fps"]))
619
619
  # 截图 = 视口(device_scale_factor=1),所以页面画布的可视区域必须正好铺满视口。
620
- # 以前不查这一条,于是 style=None + --width 1280 --height 720 会"成功"输出 1280x720:
620
+ # 不查这一条的话,style=None + --width 1280 --height 720 会"成功"输出 1280x720:
621
621
  # 页面画布仍是 1920x1080(没有 __STYLE__ 就没人改它),截出来的是原画左上角一块
622
622
  # ——实测与 1920 帧左上角的 1280x720 裁切逐像素完全相同(422 KB PNG,0 像素差异),
623
623
  # 标题 / HUD / 时间码全被裁掉,而 CLI 照旧 exit 0。画布比视口小则是四边留黑边,
@@ -648,7 +648,7 @@ async def open_page(pw, url, width=W, height=H, log=False, deterministic=None,
648
648
  % (left, top, cw, ch, int(width), int(height)))
649
649
  except BaseException:
650
650
  # 用 BaseException 而不是 Exception:worker 被取消(task.cancel)抛的是
651
- # CancelledError,它不继承 Exception —— 以前这条路径会把刚启动的浏览器丢下不管。
651
+ # CancelledError,它不继承 Exception —— 用 Exception 会把刚启动的浏览器丢下不管。
652
652
  try:
653
653
  await browser.close()
654
654
  except Exception:
@@ -691,7 +691,7 @@ async def _page_meta_async(html_path, deterministic=None, style=None, width=W, h
691
691
  def page_meta(html_path, *, style=None, width=W, height=H, fps=None):
692
692
  """打开一次页面,读回 (DURATION, FPS)。
693
693
 
694
- v0.8.0:style 非 None 时按注入后的页面读(注入可能改画布尺寸,帧率仍以页面为准,
694
+ style 非 None 时按注入后的页面读(注入可能改画布尺寸,帧率仍以页面为准,
695
695
  除非调用方显式传 fps 并在 payload 里断言)。
696
696
  """
697
697
  _setup_stdout()
@@ -703,7 +703,7 @@ def snap(html_path, times, out_dir, tag="", deterministic=None, *, style=None,
703
703
  width=W, height=H, fps=None) -> list:
704
704
  """截取指定时刻的静帧(PNG);多帧时额外拼一张联系表。返回帧 PNG 路径列表。
705
705
 
706
- v0.8.0:style/width/height/fps 透传给 open_page(缺省 = 旧行为:1920x1080、不注入)。
706
+ style/width/height/fps 透传给 open_page(缺省 = 1920x1080、不注入)。
707
707
  """
708
708
  _setup_stdout()
709
709
  tlist = [float(t) for t in times]
@@ -806,7 +806,7 @@ def _norm_events(raw) -> list:
806
806
  def export_cues(html_path, deterministic=None, *, style=None, width=W, height=H, fps=None) -> dict:
807
807
  """导出页面登记的 cue / 视觉事件,返回可直接写 work/cues.json 的字典(契约 §4.2)。
808
808
 
809
- v0.8.0:width/height 写进返回头(打完 style 注入后画布可能不是 1080p),其余照旧。
809
+ width/height 写进返回头(打完 style 注入后画布可能不是 1080p),其余照旧。
810
810
  """
811
811
  _setup_stdout()
812
812
  return asyncio.run(_export_cues_async(html_path, deterministic,
@@ -823,7 +823,7 @@ async def _export_cues_async(html_path, deterministic=None, *, style=None,
823
823
  try:
824
824
  duration = await page_number(page, "DURATION")
825
825
  fps = await page_number(page, "FPS", default=FPS)
826
- # 页面没导出 getCues/getEvents 时,以前 `? : []` 会静默返回空数组:cues.json 里
826
+ # 页面没导出 getCues/getEvents 时,`? : []` 兜底会静默返回空数组:cues.json 里
827
827
  # 0 条提示点,配乐/音效阶段照样跑完,只是整片悄悄变哑、音画同步也没有可配的事件
828
828
  # ——「静默丢一整个约定」比报错难查得多。engine.js 本身就导出这两个函数(没有提示
829
829
  # 点时返回空数组也算导出),所以 fail-closed 不会误伤正常页面。
@@ -882,7 +882,7 @@ def _chunk_dir(out_mp4, from_s: float, to_s: float, token: str = "") -> Path:
882
882
  """块目录:<stem>_<from>-<to>[_<token>]。
883
883
 
884
884
  token 只在"画布尺寸不是 1920x1080 或注入了 style"时非空——不同画风/尺寸的块不能混用,
885
- 否则断点续跑会把旧画风的块拼进新片(花片)。token 为空时目录名与 v0.7 完全一致。
885
+ 否则断点续跑会把旧画风的块拼进新片(花片)。token 为空时目录名保持不变,续跑才能命中。
886
886
  """
887
887
  out = Path(out_mp4)
888
888
  name = "%s_%g-%g" % (out.stem, float(from_s), float(to_s))
@@ -892,7 +892,7 @@ def _chunk_dir(out_mp4, from_s: float, to_s: float, token: str = "") -> Path:
892
892
 
893
893
 
894
894
  def _style_token(payload, width: int, height: int) -> str:
895
- """分块目录的风格后缀;默认尺寸 + 无 style 时返回空串(保持 v0.7 的目录名)。"""
895
+ """分块目录的风格后缀;默认尺寸 + 无 style 时返回空串(保持既有目录名,续跑才能命中)。"""
896
896
  if payload is None and (int(width), int(height)) == (W, H):
897
897
  return ""
898
898
  parts = ["w%dh%d" % (int(width), int(height))]
@@ -941,8 +941,8 @@ def _probe_duration(path):
941
941
  def _check_chunk_durations(chunks, fps: float) -> None:
942
942
  """块时长校验:块损坏 / 旧参数残留都会在拼接前被拦下。
943
943
 
944
- v0.4.0:探测不出时长的块(空文件 / 非视频 / ffprobe 失败)以前是 continue 静默放过,
945
- 结果 concat 时才发现;现在一并算异常——"读不出时长"本身就说明块是坏的。
944
+ 探测不出时长的块(空文件 / 非视频 / ffprobe 失败)不许 continue 静默放过——那要等到
945
+ concat 才发现;一并算异常:"读不出时长"本身就说明块是坏的。
946
946
  """
947
947
  bad = []
948
948
  for f0, f1, path in chunks:
@@ -1098,7 +1098,7 @@ def render_full(html_path, out_mp4, duration, *, workers=2, chunk_s=30.0,
1098
1098
  duration 传 None 时读页面 window.DURATION;传数字时与页面对不上会直接 raise。
1099
1099
  deterministic=True(或 WEBENGINE_DETERMINISTIC=1)→ 软件光栅,跨会话 byte 可复现。
1100
1100
 
1101
- v0.8.0:style 非 None 时整片跟随风格包(每个 worker 都在页面加载前注入同一份
1101
+ style 非 None 时整片跟随风格包(每个 worker 都在页面加载前注入同一份
1102
1102
  __STYLE__),块目录名会带尺寸 + style 指纹后缀,**换风格不会复用旧块**;
1103
1103
  width/height 只收 16:9 横屏偶数(_validate_canvas),fps 非 None 时要求页面帧率一致。
1104
1104
  """
@@ -1121,7 +1121,7 @@ async def _render_full_async(html_path, out_mp4, duration, *, workers=2, chunk_s
1121
1121
  width, height = _validate_canvas(width, height)
1122
1122
  payload = normalize_style(style, width=width, height=height, fps=fps) if style is not None else None
1123
1123
 
1124
- # chunk_s <= 0 以前会被后面的 max(1, ...) 静默夹成「1 帧/块」:360 帧的 12 s 片会拆出
1124
+ # chunk_s <= 0 会被后面的 max(1, ...) 静默夹成「1 帧/块」:360 帧的 12 s 片会拆出
1125
1125
  # 360 个块,每块单独起一个 ffmpeg 进程再拼接,慢到没法用,而且日志里只看得出"块很多",
1126
1126
  # 看不出是自己传错了参数。放在开浏览器之前校验,参数错了不用白等一次页面加载。
1127
1127
  chunk_s = float(chunk_s)
@@ -1223,10 +1223,10 @@ QA_BRIEF_S = 0.6
1223
1223
 
1224
1224
 
1225
1225
  def _check_qa_window(t0, t1, step):
1226
- """文字质检扫描参数校验;非法 raise ValueError(以前非法参数会被静默吞成空结果 + exit 0)。
1226
+ """文字质检扫描参数校验;非法 raise ValueError(否则非法参数会被静默吞成空结果 + exit 0)。
1227
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 的闪
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
1230
  字而永远报 brief=0。空扫描 / 扫不到的扫描都不能算"没有缺陷"。
1231
1231
  """
1232
1232
  try:
@@ -1260,7 +1260,7 @@ _QA_LIST_KEYS = ("overlaps", "offscreen", "brief", "never")
1260
1260
  def _check_qa_result(result):
1261
1261
  """校验页面 qaText 的返回值形状;形状不对 raise RuntimeError(不能让缺字段=全 0=PASS)。
1262
1262
 
1263
- 历史坑:以前只判 `isinstance(result, dict)`,页面 `qaText=()=>({})` 时 CLI 把四类缺陷
1263
+ 只判 `isinstance(result, dict)` 不够:页面 `qaText=()=>({})` 时 CLI 会把四类缺陷
1264
1264
  都打印成 0 并 exit 0——**假通过**。契约要求四个数组 + frames(实际采样点数),
1265
1265
  这里连 frames<=0 也拦(扫描 0 帧等于没查)。
1266
1266
  """
@@ -1287,11 +1287,11 @@ def qa_text(html_path, t0=0.0, t1=None, step=0.2, deterministic=None, *,
1287
1287
  style=None, width=W, height=H, fps=None) -> dict:
1288
1288
  """浏览器端文字质检:重叠 / 出画 / 一闪而过 / 从没露面(渲染期间会临时改页面状态)。
1289
1289
 
1290
- v0.8.0:跟着 style 一起检查——换了浅色主题/非 1080p 画布后,出画与重叠的判据必须用
1290
+ 跟着 style 一起检查——换了浅色主题/非 1080p 画布后,出画与重叠的判据必须用
1291
1291
  同一份画布尺寸(qa_text.js 读 FK.W / FK.H),否则"检查通过"只是没在检查新尺寸。
1292
1292
 
1293
- 深审修复:参数与返回值 fail-closed(之前空扫描 / 扫不到的扫描 / 页面返回 {} 都是
1294
- exit 0,门禁看着是绿的,其实什么都没查)。见 _check_qa_window / _check_qa_result。
1293
+ 参数与返回值 fail-closed(空扫描 / 扫不到的扫描 / 页面返回 {} 如果都 exit 0,
1294
+ 门禁看着是绿的,其实什么都没查)。见 _check_qa_window / _check_qa_result。
1295
1295
  """
1296
1296
  _setup_stdout()
1297
1297
  t0, t1, step = _check_qa_window(t0, t1, step)
@@ -1319,14 +1319,14 @@ async def _qa_text_async(html_path, t0, t1, step, deterministic=None, *,
1319
1319
  return _check_qa_result(result)
1320
1320
 
1321
1321
 
1322
- # ---------------------------------------------------------------- 指定帧号截 PNG(v0.8.0)
1322
+ # ---------------------------------------------------------------- 指定帧号截 PNG
1323
1323
  def render_frames(html_path, frames, out_dir=None, *, tag="", style=None, width=W, height=H,
1324
1324
  fps=None, deterministic=None) -> list:
1325
1325
  """把**指定帧号**渲成 PNG,返回 [{"f","path","sha256","bytes","fps"}](不编码视频)。
1326
1326
 
1327
- 这是 v0.8.0 给 render.py / 自检 / 人工验收用的最小确定性入口:
1327
+ 这是给 render.py / 自检 / 人工验收用的最小确定性入口:
1328
1328
  帧号 → 时间 = f / FPS(与 render_full 同一口径),不存在"按播放时间截屏"。
1329
- style=None 时不注入 __STYLE__(像素与 v0.7 一致);传 style 时整页跟随风格包。
1329
+ style=None 时不注入 __STYLE__(像素与默认外观一致);传 style 时整页跟随风格包。
1330
1330
  out_dir 缺省 ./frames;文件名 <tag>f<帧号:05d>.png。
1331
1331
  """
1332
1332
  _setup_stdout()
@@ -1466,7 +1466,7 @@ async def _selftest_probe() -> dict:
1466
1466
  return out
1467
1467
 
1468
1468
 
1469
- # ---------------------------------------------------------------- 自检辅助(v0.4.0)
1469
+ # ---------------------------------------------------------------- 自检辅助
1470
1470
  _NONDET_JS = re.compile(r"(Math\.random|Date\.now|performance\.now|new\s+Date\s*\()")
1471
1471
 
1472
1472
 
@@ -1513,7 +1513,7 @@ async def _open_page_expect_error(html_path, ready_timeout=3000) -> str:
1513
1513
  raise RuntimeError("坏页面居然没有报错:%s" % html_path)
1514
1514
 
1515
1515
 
1516
- # ---------------------------------------------------------------- 自检:风格注入(v0.8.0)
1516
+ # ---------------------------------------------------------------- 自检:风格注入
1517
1517
  # 自检用的主题故意选"浅色 + 衬线 + 右对齐 + 四角线框":与模板默认的深蓝橙 + 居中 + 无框
1518
1518
  # 在每一次可观测维度上都不同,任何一处没跟着 __STYLE__ 走都会暴露成"两张图一样"。
1519
1519
  _SELFTEST_STYLE = {
@@ -1605,7 +1605,7 @@ def selftest() -> int:
1605
1605
  print(("[PASS] " if ok else "[FAIL] ") + title + ((" —— " + detail) if detail else ""), flush=True)
1606
1606
 
1607
1607
  print("=" * 78)
1608
- print("webengine.py --selftest(v0.8.0):无头浏览器逐帧渲染 / 确定性 / cue / 文字质检 / 风格注入")
1608
+ print("webengine.py --selftest:无头浏览器逐帧渲染 / 确定性 / cue / 文字质检 / 风格注入")
1609
1609
  print("=" * 78)
1610
1610
 
1611
1611
  names = ("index.html", "engine.js", "film.js", "style.css", "qa_text.js")
@@ -1792,7 +1792,7 @@ def selftest() -> int:
1792
1792
  check("不同帧渲染结果不同", bool(probe.get("diff_frames")),
1793
1793
  "f=45 与 f=46 的 PNG 不同" if probe.get("diff_frames") else (perr or "相邻帧截图相同(时间码没变?)"))
1794
1794
 
1795
- # ---- v0.8.0:画布尺寸 / 风格注入 ----
1795
+ # ---- 画布尺寸 / 风格注入 ----
1796
1796
  good_sizes, bad_sizes, portrait_msg = [], [], ""
1797
1797
  for w_, h_ in ((1920, 1080), (1280, 720), (3840, 2160)):
1798
1798
  try:
@@ -1820,7 +1820,7 @@ def selftest() -> int:
1820
1820
  ";".join(bad_sizes) or ("合法:%s;竖屏报错点名竖屏" % "、".join(good_sizes)))
1821
1821
 
1822
1822
  # ---- 深审:确定性开关三态 / 文字质检参数与返回值 fail-closed ----
1823
- # 这三条都是纯函数校验(不开浏览器),专门守"门禁看着干净、其实没在查"的三条历史路径:
1823
+ # 这三条都是纯函数校验(不开浏览器),专门守"门禁看着干净、其实没在查"的三条路径:
1824
1824
  # · CLI --deterministic 用 store_true 时默认 False 会压掉 WEBENGINE_DETERMINISTIC;
1825
1825
  # · textqa 空扫描(t1<t0)/ 步长 >= brief 阈值时返回全 0 并 exit 0;
1826
1826
  # · 页面 qaText 返回 {} / frames=0 时同样被当成"零缺陷"。
@@ -2060,7 +2060,7 @@ def selftest() -> int:
2060
2060
  % (len(bad_qa.get("overlaps") or []), len(bad_qa.get("offscreen") or []),
2061
2061
  len(bad_qa.get("brief") or []), len(bad_qa.get("never") or []))))
2062
2062
 
2063
- # 坏页面 3b:CLI textqa 遇到这四类必须 exit 1(以前只把重叠/出画算失败)
2063
+ # 坏页面 3b:CLI textqa 遇到这四类必须 exit 1(只把重叠/出画算失败就漏了后两类)
2064
2064
  cli_bad = None
2065
2065
  try:
2066
2066
  cp = subprocess.run(
@@ -2160,10 +2160,13 @@ def _parse_frames(text: str) -> list:
2160
2160
 
2161
2161
 
2162
2162
  def _resolve_style_spec(text):
2163
- """解析 --style 的三种写法:内联 JSON / JSON 文件路径 / stylekit 风格 id。
2163
+ """解析 --style 的**两种**写法:内联 JSON / JSON 文件路径。
2164
2164
 
2165
- 返回 normalize_style 能吃的 dict;空字符串返回 None(= 不注入,旧行为)。
2165
+ 返回 normalize_style 能吃的 dict;空字符串返回 None(= 不注入)。
2166
2166
  解析失败一律 SystemExit(CLI 场景,带可操作提示),不做静默回落。
2167
+
2168
+ 不接受"风格 id"这种写法:没有内置外观表,`--style magazine` 没有对象可指。
2169
+ 与其抛一个"没有风格 X"的错,不如直接把两种合法写法和替代方案说清楚。
2167
2170
  """
2168
2171
  s = str(text or "").strip()
2169
2172
  if not s:
@@ -2179,24 +2182,20 @@ def _resolve_style_spec(text):
2179
2182
  return json.loads(p.read_text(encoding="utf-8"))
2180
2183
  except json.JSONDecodeError as exc:
2181
2184
  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]}
2185
+ raise SystemExit(
2186
+ "[错误] --style %r 不是 JSON、也不是存在的文件——而**按内置风格 id 取外观这条路已经没有了**。\n"
2187
+ " 本技能不提供内置风格/主题表。现在注入外观只有两种写法:\n"
2188
+ " ① --style '{\"theme\": {...}, \"canvas\": {...}}' # 内联 JSON\n"
2189
+ " ② --style work/style.json # 由 render.py --stage style 落盘\n"
2190
+ " 想生成一套新外观:python propose.py --brief \"题材\" --script film.json\n"
2191
+ " 详细契约见 references/web-engine.md 的 __STYLE__ 段。" % (s,))
2194
2192
 
2195
2193
 
2196
2194
  def _add_style_args(p, *, fps=True):
2197
- """给子命令加风格参数(缺省 = v0.7 旧行为:不注入、1920x1080)。"""
2195
+ """给子命令加风格参数(缺省 = 不注入、1920x1080)。"""
2198
2196
  p.add_argument("--style", default="",
2199
- help="风格:stylekit 风格 id(如 magazine)/ work/style.json / 内联 JSON;缺省不注入")
2197
+ help="外观载荷:work/style.json / 内联 JSON;缺省不注入"
2198
+ "(不接受风格 id:内置风格表已删除)")
2200
2199
  p.add_argument("--width", type=int, default=W, help="画布宽(只收 16:9 横屏偶数,默认 %d)" % W)
2201
2200
  p.add_argument("--height", type=int, default=H, help="画布高(默认 %d)" % H)
2202
2201
  if fps:
@@ -2207,7 +2206,7 @@ def _add_style_args(p, *, fps=True):
2207
2206
  def _build_parser() -> argparse.ArgumentParser:
2208
2207
  ap = argparse.ArgumentParser(
2209
2208
  prog="webengine.py",
2210
- description="浏览器逐帧渲染引擎(v0.8.0):Canvas 页面 → CDP 截图 → ffmpeg → mp4(支持风格包注入)",
2209
+ description="浏览器逐帧渲染引擎:Canvas 页面 → CDP 截图 → ffmpeg → mp4(支持风格包注入)",
2211
2210
  )
2212
2211
  ap.add_argument("--selftest", action="store_true", help="跑自检(不联网,≤60 秒)")
2213
2212
  sub = ap.add_subparsers(dest="cmd")
@@ -2287,7 +2286,7 @@ def main(argv=None) -> int:
2287
2286
  return 2
2288
2287
 
2289
2288
  # --style 统一在分支之前解析:内联 JSON / JSON 文件 / 风格 id 三种写法,解析失败算用法
2290
- # 错误(exit 2)。以前每个分支各自解析,坏 id 会抛 SystemExit 变成 exit 1,脚本里分不清
2289
+ # 错误(exit 2)。每个分支各自解析时,坏 id 会抛 SystemExit 变成 exit 1,脚本里分不清
2291
2290
  # "参数写错"和"渲染失败"。
2292
2291
  try:
2293
2292
  style_spec = _resolve_style_spec(getattr(args, "style", ""))
@@ -2304,7 +2303,7 @@ def main(argv=None) -> int:
2304
2303
  % (args.times,), flush=True)
2305
2304
  return 2
2306
2305
  if args.every is not None and float(args.every) <= 0:
2307
- # 以前 --every 0 被当成"没写"、--every -1 则是死循环:先 page_meta 开一次浏览器,
2306
+ # --every 0 会被当成"没写"、--every -1 则是死循环:先 page_meta 开一次浏览器,
2308
2307
  # 之后 while k <= end 永远成立,times 一路追加到内存爆掉(实测 40 s 都出不来)。
2309
2308
  print("[错误] --every 必须是正数秒(当前 %r);不想等间隔采样就别传 --every" % (args.every,),
2310
2309
  flush=True)
@@ -2360,7 +2359,7 @@ def main(argv=None) -> int:
2360
2359
  return 2
2361
2360
  except RuntimeError as exc:
2362
2361
  # 页面契约 / 浏览器起不来这类错误(缺 getCues、ready 超时、缺 playwright…):
2363
- # 以前直接抛 traceback(exit 1,且报错淹没在栈里)。这是"要改页面/环境"的用法
2362
+ # 直接抛 traceback 会变成 exit 1,且报错淹没在栈里。这是"要改页面/环境"的用法
2364
2363
  # 错误,和 --style 写坏一样给 exit 2 + 一行可读原因。
2365
2364
  print("[错误] %s" % exc, flush=True)
2366
2365
  return 2
@@ -2397,7 +2396,7 @@ def main(argv=None) -> int:
2397
2396
  return 2
2398
2397
  except RuntimeError as exc:
2399
2398
  # 同 cues:页面 qaText 返回值形状不对 / 契约缺失 / 浏览器起不来都属于"要改页面
2400
- # 或环境",给 exit 2 + 可读原因;不再让一个 traceback 决定门禁红绿。
2399
+ # 或环境",给 exit 2 + 可读原因;别让一个 traceback 决定门禁红绿。
2401
2400
  print("[错误] %s" % exc, flush=True)
2402
2401
  return 2
2403
2402
  print("[textqa] 重叠 %d、出画 %d、一闪而过 %d、从没露面 %d"
@@ -2412,7 +2411,7 @@ def main(argv=None) -> int:
2412
2411
  if args.out:
2413
2412
  print("[textqa] 详情 → %s" % _json_dump(result, args.out), flush=True)
2414
2413
  # 四类缺陷都算没通过:重叠/出画是布局错,一闪而过/从没露面同样会让成片看起来"漏字"。
2415
- # v0.4.0 之前只看前两类,后两类会被打印出来却 exit 0 —— 门禁形同建议。
2414
+ # 只看前两类的话,后两类会被打印出来却 exit 0 —— 门禁形同建议。
2416
2415
  bad = (bool(result.get("overlaps")) or bool(result.get("offscreen"))
2417
2416
  or bool(result.get("brief")) or bool(result.get("never")))
2418
2417
  return 1 if bad else 0