@dsh-gis/dsh-video-studio-skill 0.6.0 → 0.7.2

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 (32) hide show
  1. package/README.md +41 -16
  2. package/index.js +20 -7
  3. package/package.json +4 -2
  4. package/schemas/film.schema.json +75 -4
  5. package/skills/video-studio/SKILL.md +95 -23
  6. package/skills/video-studio/assets/film.json +7 -14
  7. package/skills/video-studio/assets/scenes.py +115 -10
  8. package/skills/video-studio/assets/web/engine.js +18 -1
  9. package/skills/video-studio/assets/web/film.js +29 -4
  10. package/skills/video-studio/assets/web/index.html +5 -0
  11. package/skills/video-studio/references/audio-variation.md +158 -0
  12. package/skills/video-studio/references/audio.md +67 -5
  13. package/skills/video-studio/references/change-impact.md +1 -1
  14. package/skills/video-studio/references/quality-gates.md +1 -1
  15. package/skills/video-studio/references/web-engine.md +1 -1
  16. package/skills/video-studio/requirements.txt +2 -1
  17. package/skills/video-studio/scripts/filmcheck.py +81 -12
  18. package/skills/video-studio/scripts/filmkit.py +271 -18
  19. package/skills/video-studio/scripts/gates.py +75 -12
  20. package/skills/video-studio/scripts/qa_frames.py +109 -19
  21. package/skills/video-studio/scripts/render.py +420 -51
  22. package/skills/video-studio/scripts/scorekit.py +289 -46
  23. package/skills/video-studio/scripts/selfcheck.py +302 -2
  24. package/skills/video-studio/scripts/sfxkit.py +132 -30
  25. package/skills/video-studio/scripts/stagewatch.py +141 -31
  26. package/skills/video-studio/scripts/stylekit.py +94 -6
  27. package/skills/video-studio/scripts/voicekit.py +8 -0
  28. package/skills/video-studio/scripts/webengine.py +68 -14
  29. package/tools/audio_similarity.py +279 -0
  30. package/tools/ci_check.py +145 -34
  31. package/tools/estimate.py +133 -54
  32. package/tools/selftest_noscipy.py +70 -0
@@ -54,13 +54,41 @@ def _sha1_file(path):
54
54
  return "missing"
55
55
 
56
56
 
57
- def content_hash(script=None, scenes_path="", script_path="", engine="pil"):
58
- """本次渲染的"内容身份":剧本 + 场景代码 + 引擎。
57
+ WEB_ASSET_EXT = (".js", ".mjs", ".html")
58
+ # web 引擎的画面逻辑在页面脚本里,不在 scenes.py 里。这几个后缀按文件名排序后逐个取
59
+ # sha1,再合并成一个指纹;不含 .css(样式改动不影响"画面语义",也确实常见于调色试错)。
60
+
61
+
62
+ def web_asset_fingerprint(entry):
63
+ """入口页面同目录下 .js/.mjs/.html 的内容指纹(按文件名排序后拼接)。
64
+
65
+ 返回 (combined, files):combined 是 16 位 hex(目录读不了返回 "missing");
66
+ files 是参与计算的 [文件名],只用于报告/排错,不进指纹(避免绝对路径进 hash)。
67
+ """
68
+ if not entry:
69
+ return "missing", []
70
+ d = os.path.dirname(os.path.abspath(str(entry)))
71
+ try:
72
+ names = sorted(n for n in os.listdir(d) if n.lower().endswith(WEB_ASSET_EXT))
73
+ except OSError:
74
+ return "missing", []
75
+ if not names:
76
+ return "missing", []
77
+ rows = []
78
+ for n in names:
79
+ rows.append("%s:%s" % (n, _sha1_file(os.path.join(d, n))))
80
+ return _sha1_text("\n".join(rows)), names
81
+
82
+
83
+ def content_hash(script=None, scenes_path="", script_path="", engine="pil", web_entry=""):
84
+ """本次渲染的"内容身份":剧本 + 场景代码 + 引擎(web 时再加页面脚本)。
59
85
 
60
86
  只取与"这一版画面/声音"相关的量:
61
87
  * 剧本:去掉渲染期注入的 _path/_dir/_style 等私有键后规范化 json;
62
88
  * scenes.py:文件内容(画面逻辑变了,旧签核就得失效);
63
- * engine:pil 与 web 是两条渲染路径,产物不可互换。
89
+ * engine:pil 与 web 是两条渲染路径,产物不可互换;
90
+ * web_entry:engine=web 时,入口页同目录的 .js/.mjs/.html 一起算。
91
+ **pil 路线不参与**——否则老 PIL 项目每次升级都会被判"内容已变",签核无故失效。
64
92
  """
65
93
  body = {}
66
94
  if isinstance(script, dict):
@@ -68,19 +96,24 @@ def content_hash(script=None, scenes_path="", script_path="", engine="pil"):
68
96
  if str(k).startswith("_"):
69
97
  continue
70
98
  body[k] = v
99
+ eng = str(engine or "pil")
71
100
  payload = {
72
101
  "script": _sha1_text(json.dumps(body, sort_keys=True, ensure_ascii=False, default=str)),
73
102
  "script_file": _sha1_file(script_path) if script_path else "",
74
103
  "scenes": _sha1_file(scenes_path) if scenes_path else "",
75
- "engine": str(engine or "pil"),
104
+ "engine": eng,
76
105
  }
106
+ if eng == "web":
107
+ assets, _names = web_asset_fingerprint(web_entry)
108
+ payload["web_assets"] = assets
77
109
  return _sha1_text(json.dumps(payload, sort_keys=True))
78
110
 
79
111
 
80
112
  class GateLedger:
81
113
  """一次运行内的门禁账本(多 stage 共用;--stage all 时是同一个对象)。"""
82
114
 
83
- def __init__(self, work, script=None, scenes_path="", script_path="", engine="pil"):
115
+ def __init__(self, work, script=None, scenes_path="", script_path="", engine="pil",
116
+ web_entry=""):
84
117
  self.work = str(work or "work")
85
118
  self.items = []
86
119
  self.t0 = time.time()
@@ -88,7 +121,12 @@ class GateLedger:
88
121
  self.scenes_path = str(scenes_path or "")
89
122
  self.script_path = str(script_path or "")
90
123
  self.engine = str(engine or "pil")
91
- self.content_hash = content_hash(script, self.scenes_path, self.script_path, self.engine)
124
+ self.web_entry = str(web_entry or "")
125
+ self.web_assets = []
126
+ self.content_hash = content_hash(script, self.scenes_path, self.script_path,
127
+ self.engine, web_entry=self.web_entry)
128
+ if self.engine == "web":
129
+ _fp, self.web_assets = web_asset_fingerprint(self.web_entry)
92
130
  self.bypassed = [] # --no-gate 绕过的记录(必须留痕)
93
131
 
94
132
  # ---- 记账 ----
@@ -142,9 +180,13 @@ class GateLedger:
142
180
 
143
181
  # ---- 落盘 ----
144
182
  def to_dict(self):
145
- return dict(schema=SCHEMA, at=self.t0, contentHash=self.content_hash,
146
- engine=self.engine, counts=self.counts(),
147
- items=list(self.items), bypassed=list(self.bypassed))
183
+ d = dict(schema=SCHEMA, at=self.t0, contentHash=self.content_hash,
184
+ engine=self.engine, counts=self.counts(),
185
+ items=list(self.items), bypassed=list(self.bypassed))
186
+ if self.engine == "web":
187
+ # 审计用:让用户/CI 能看出这个指纹到底盖了哪些页面脚本(不进 hash,绝对路径不外泄)
188
+ d["webAssets"] = list(self.web_assets)
189
+ return d
148
190
 
149
191
  def write_json(self):
150
192
  """work/gates.json —— 给程序读(QC-REPORT.md 只是它的渲染层)。"""
@@ -334,7 +376,8 @@ def render_report(work, out=None):
334
376
  (v.get("detail") or "-").replace("|", "\\|")))
335
377
  if stale:
336
378
  L.append("")
337
- L.append("> 内容已变的签核:%s(改了剧本 / scenes.py / 引擎,旧签核自动失效,需重新签)"
379
+ L.append("> 内容已变的签核:%s(改了剧本 / scenes.py / 引擎 / web 页面脚本,"
380
+ "旧签核自动失效,需重新签)"
338
381
  % "、".join("`%s`" % x for x in stale))
339
382
  L.append("")
340
383
  L.append("## 三、耗时台账(`work/cost.jsonl` 实测,非估算)")
@@ -366,7 +409,17 @@ def render_report(work, out=None):
366
409
  nxt = "四"
367
410
  L.append("## %s、结论(照抄,别自己发挥)" % nxt)
368
411
  L.append("")
369
- if counts.get(FAIL):
412
+ if not items:
413
+ # 没有门禁记录 ≠ 全部通过。以前这里落到 else 分支打印"**通过**",
414
+ # 于是 gates.json 被删 / 写坏 / 根本没跑 qa 时,QC-REPORT.md 会是一份
415
+ # 宣称通过的交付凭据——正是本模块开头要防的"看起来干净、其实没在查"。
416
+ if not os.path.exists(gj):
417
+ why = "没有 %s(本次没有跑过任何 stage,或 work/ 目录已丢)" % gj
418
+ else:
419
+ why = "%s 读不出来 / 里面 0 条门禁(JSON 损坏或并发写坏)" % gj
420
+ L.append("- **未判定**:%s。**这不等于通过**——先跑 --stage qa(或 --stage all)"
421
+ "产出真实门禁再交付。" % why)
422
+ elif counts.get(FAIL):
370
423
  L.append("- **未通过**:fail %d 项,按上表逐条修,别宣称完成。" % counts.get(FAIL))
371
424
  elif counts.get(SKIP):
372
425
  L.append("- **有条件通过**:skipped %d 项(明确没执行),交付时必须逐项说明原因。"
@@ -439,7 +492,17 @@ if __name__ == "__main__":
439
492
  p = write_report(a.work, a.out or None)
440
493
  print("[QC 报告]", p)
441
494
  if a.json:
442
- print(json.dumps(GateLedger(a.work).to_dict(), ensure_ascii=False, indent=1))
495
+ # 读磁盘上的账本,别现场 new 一个空的:GateLedger(work) 不传剧本时
496
+ # contentHash 是按空剧本算的,打出来的 JSON 跟 work/gates.json 对不上、
497
+ # items 永远是 [] —— 机读输出等于撒谎。
498
+ gp = os.path.join(str(a.work), "gates.json")
499
+ try:
500
+ with open(gp, encoding="utf-8") as fh:
501
+ print(json.dumps(json.load(fh), ensure_ascii=False, indent=1))
502
+ except (OSError, json.JSONDecodeError) as err:
503
+ print(json.dumps(dict(ok=False, work=str(a.work),
504
+ error="没有可读的 %s:%s" % (gp, err)),
505
+ ensure_ascii=False, indent=1))
443
506
  sys.exit(0)
444
507
  except (ValueError, OSError) as e:
445
508
  print("[ERROR] %s" % e, file=sys.stderr)
@@ -213,6 +213,7 @@ def _probe(path) -> dict:
213
213
  path = str(path)
214
214
  info = {"fps": FPS_FALLBACK, "duration": 0.0, "width": None, "height": None,
215
215
  "has_audio": False, "sample_rate": None, "duration_source": "none"}
216
+ fps_parsed = False # ffprobe 已经给出合法帧率?(拿它判要不要跑 ffmpeg -i 兜底)
216
217
  p = subprocess.run(
217
218
  [_ffprobe(), "-v", "error",
218
219
  "-show_entries",
@@ -247,6 +248,7 @@ def _probe(path) -> dict:
247
248
  fps = _parse_rate(st.get("avg_frame_rate"))
248
249
  if fps and 1.0 <= fps <= 240.0:
249
250
  info["fps"] = float(fps)
251
+ fps_parsed = True
250
252
  if not info["duration"]:
251
253
  try:
252
254
  vd = float(st.get("duration") or 0.0)
@@ -262,7 +264,10 @@ def _probe(path) -> dict:
262
264
  info["sample_rate"] = int(st.get("sample_rate") or 0) or None
263
265
  except Exception: # noqa: BLE001
264
266
  pass
265
- if info["width"] is None or info["fps"] == FPS_FALLBACK or not info["duration"]:
267
+ # 只在 ffprobe 真的没给全信息时才回退到 ffmpeg -i。
268
+ # 旧条件写的是 info["fps"] == FPS_FALLBACK,于是**所有 30 fps 成片**都会白跑一次
269
+ # ffmpeg -i(实测每片多一次进程 + 全文件解析),而 30fps 恰恰是本项目最常见情况。
270
+ if info["width"] is None or not fps_parsed or not info["duration"]:
266
271
  txt = ""
267
272
  try:
268
273
  q = subprocess.run([_ffmpeg(), "-hide_banner", "-i", path], capture_output=True)
@@ -624,6 +629,30 @@ def _event_kind(ev) -> str:
624
629
  return str(ev.get("kind") or ev.get("type") or "").strip().lower()
625
630
 
626
631
 
632
+ def _src_of(obj) -> str:
633
+ """取 cue / event 的来源标记(web / pil / file / …);没写返回空串。"""
634
+ try:
635
+ return str(obj.get("src") or "").strip().lower()
636
+ except AttributeError: # noqa: BLE001 —— 坏条目当"没写"
637
+ return ""
638
+
639
+
640
+ def _src_compatible(cue, ev) -> bool:
641
+ """两个条目能不能做同步配对:只有**双方都明确标了来源且不同**时才认为时间基准不同。
642
+
643
+ v0.7.1 起 render.py 在 engine=web 时把三路 cue 合并(film.json / scenes.py / 页面),
644
+ 其中 scenes.py 的 cue 按 PIL 时间轴、页面事件按页面时间轴——跨基准配对会报假 FAIL
645
+ (实测:cue ui_blip @7.725s 配 页面事件 @9.550s,dt=+1.825s)。
646
+ 没标 src 的条目(手写 events / 老版本 cues.json)一律视作兼容,保持向后兼容。
647
+ """
648
+ cs, es = _src_of(cue), _src_of(ev)
649
+ return not (cs and es and cs != es)
650
+
651
+
652
+ def _compatible_events(cue, events) -> list:
653
+ return [e for e in (events or []) if isinstance(e, dict) and _src_compatible(cue, e)]
654
+
655
+
627
656
  def _match_cue(cue, events, tol) -> tuple[dict | None, float | None, str]:
628
657
  """给一个 cue 找对应视觉事件。
629
658
 
@@ -640,6 +669,8 @@ def _match_cue(cue, events, tol) -> tuple[dict | None, float | None, str]:
640
669
  """
641
670
  ct = float(cue.get("t") or 0.0)
642
671
  typ = str(cue.get("type") or "").strip().lower()
672
+ # 只在**同时间基准**的事件里找(跨源配对会报假 FAIL,见 _src_compatible)
673
+ events = _compatible_events(cue, events)
643
674
  same = [e for e in events if _event_kind(e) == typ]
644
675
  if same:
645
676
  ev = min(same, key=lambda e: abs(float(e.get("t") or 0.0) - ct))
@@ -677,6 +708,9 @@ def sync_check(cues, events, tol=SYNC_TOL) -> list[dict]:
677
708
  超过容差就不配对——跨 kind 的"最近事件"不是"预期事件",据此判超差会制造假 FAIL。
678
709
  配不上的 cue 视为"没有对应视觉事件",不判超差(很多音效本来就没有画面事件)。
679
710
  ⚠ 命名约定见 references/frame-qa.md §2.5:events 与 cue 不同名时本检查会静默全过。
711
+ ⚠ 来源(src)过滤(v0.7.1 起):cue 与事件**双方都标了 src 且不同**时不配对——web 引擎
712
+ 三路合并时 PIL 时间轴的 cue 与页面事件基准不同,跨基准配对会报假 FAIL。跳过数量由
713
+ _sync_coverage().src_skipped 计数并在 _load_cues 里转成 warning(不静默)。
680
714
  """
681
715
  out = []
682
716
  cues = [c for c in (cues or []) if isinstance(c, dict)]
@@ -740,22 +774,30 @@ def _cue_anchor(cue) -> float | None:
740
774
 
741
775
 
742
776
  def _sync_coverage(cues, events) -> dict:
743
- """统计"音画同步到底查了多少条":返回 {"cues","anchors","same","judged"}。
777
+ """统计"音画同步到底查了多少条":返回 {"cues","anchors","same","judged","src_skipped"}。
744
778
 
745
- judged = 能给出 dt 判定的 cue 数 = 自带锚点的 cue + 至少存在同名 kind 事件的 cue。
779
+ judged = 能给出 dt 判定的 cue 数 = 自带锚点的 cue + 至少存在同名 kind 且**同源**事件的 cue。
746
780
  这是防"静默全过"的量化指标:judged == 0 而 cues > 0 时,sync 为空没有意义。
781
+ src_skipped = 因为"cue 与事件时间基准不同"(src 不同)而无法参与对照的 cue 数:
782
+ web 引擎三路合并(film.json/scenes.py 用 PIL 时间轴、页面事件用页面时间轴)时会有,
783
+ 这种情况不能算"同步失败",但也不能静默跳过 —— 由 _load_cues 转成 warning 点名。
747
784
  """
748
785
  cues = [c for c in (cues or []) if isinstance(c, dict)]
786
+ events = [e for e in (events or []) if isinstance(e, dict)]
749
787
  anchors = sum(1 for c in cues if _cue_anchor(c) is not None)
750
- ev_kinds = {_event_kind(e) for e in (events or []) if isinstance(e, dict)}
751
788
  same = 0
789
+ src_skipped = 0
752
790
  for c in cues:
753
791
  if _cue_anchor(c) is not None:
754
792
  continue
755
- if str(c.get("type") or "").strip().lower() in ev_kinds:
793
+ compat = _compatible_events(c, events)
794
+ if not compat and events:
795
+ src_skipped += 1
796
+ continue
797
+ if str(c.get("type") or "").strip().lower() in {_event_kind(e) for e in compat}:
756
798
  same += 1
757
799
  return {"cues": len(cues), "anchors": anchors, "same": same,
758
- "judged": anchors + same}
800
+ "judged": anchors + same, "src_skipped": src_skipped}
759
801
 
760
802
 
761
803
  def _load_cues(cues_json):
@@ -789,7 +831,27 @@ def _load_cues(cues_json):
789
831
  info["blockers"].append("cues.json 结构不是对象")
790
832
  return [], [], ["cues.json 结构不是对象,跳过音画同步对照"], info
791
833
  cues = [c for c in (data.get("cues") or []) if isinstance(c, dict)]
792
- events = [e for e in (data.get("events") or []) if isinstance(e, dict)]
834
+ raw_events = [e for e in (data.get("events") or []) if isinstance(e, dict)]
835
+ # 只认"带有限数值 t"的事件:sync_check 内部本来就这么过滤(e.get("t") is not None),
836
+ # 但 _sync_coverage / 命名约定守卫以前拿的是**未过滤**的列表 —— 于是 events 里只要有
837
+ # {"kind": "impact"} 这种缺 t 的条目,judged 就会被算成 1,命名约定守卫也被骗过,
838
+ # 结果是"门禁看起来在查、实际一条都没判"且不报警(实测:cues=1/events=[{kind:impact}]
839
+ # -> coverage judged=1,report errors=[] warnings=[])。
840
+ events = []
841
+ dropped_t = 0
842
+ for e in raw_events:
843
+ t = e.get("t")
844
+ try:
845
+ tv = float(t)
846
+ except (TypeError, ValueError):
847
+ dropped_t += 1
848
+ continue
849
+ if not math.isfinite(tv):
850
+ dropped_t += 1
851
+ continue
852
+ ev = dict(e)
853
+ ev["t"] = tv
854
+ events.append(ev)
793
855
  bad_anchor = []
794
856
  for c in cues:
795
857
  for k in CUE_ANCHOR_KEYS:
@@ -800,8 +862,19 @@ def _load_cues(cues_json):
800
862
  else:
801
863
  c["_anchor_t"] = a
802
864
  break
865
+ if dropped_t:
866
+ warns.append("cues.json 有 %d 个 event 缺数值 t(秒),已忽略;"
867
+ "音画同步只看得到带时间的视觉事件" % dropped_t)
803
868
  cov = _sync_coverage(cues, events)
804
869
  info.update(cov)
870
+ if cov.get("src_skipped"):
871
+ msg = ("有 %d 条 cue 因时间基准不同未参与音画同步对照(cue 与事件的 src 不是同一来源):"
872
+ "web 引擎会把 film.json / scenes.py / 页面 cue 三路合并,其中 scenes.py 的 cue 按 "
873
+ "PIL 时间轴、页面事件按页面时间轴 —— 跨基准配对会报假 FAIL,所以跳过并在这里计数。"
874
+ "要让这些 cue 也参与对照,给 cue 写数值 visual_at(见 references/frame-qa.md §2.5)。"
875
+ % cov["src_skipped"])
876
+ warns.append(msg)
877
+ info.setdefault("notes", []).append(msg)
805
878
  if bad_anchor:
806
879
  warns.append("有 %d 条 cue 的 %s 不是数值秒,已忽略(只认数字,如 12.42):%s"
807
880
  % (len(bad_anchor), "/".join(CUE_ANCHOR_KEYS),
@@ -819,14 +892,14 @@ def _load_cues(cues_json):
819
892
  warns.append(msg)
820
893
  info["blockers"].append(msg)
821
894
  elif cues and events:
822
- # 门禁静默失效的最后一道保险:同 kind 配对要求 events.kind == cue.type。
895
+ # 门禁静默失效的最后一道保险:同 kind 配对要求 events.kind == cue.type,
896
+ # 且按来源(src)过滤后仍要有可配对项(见 _src_compatible)。
823
897
  # 若一个都对不上,sync_check 会"零超差"通过——看起来干净,其实一条都没在查。
824
898
  cue_types = {str(c.get("type") or "").strip().lower() for c in cues}
825
899
  ev_kinds = {_event_kind(e) for e in events}
826
- paired = cue_types & ev_kinds
827
- if not paired and cov["anchors"] == 0:
828
- msg = ("音画同步检查可能没生效:%d 个 cue 的 type 与 %d 个事件的 kind 没有任何同名项"
829
- "(同 kind 配对要求 events.kind == cue 的配方名)。"
900
+ if cov["same"] == 0 and cov["anchors"] == 0:
901
+ msg = ("音画同步检查可能没生效:%d 个 cue 的 type 与 %d 个事件的 kind 没有任何可配对的同名项"
902
+ "(同 kind 配对要求 events.kind == cue 的配方名,且双方 src 需属同一时间基准)。"
830
903
  "sync 为空不代表同步正确——请按 references/frame-qa.md §2.5 的命名约定登记事件,"
831
904
  "或把某个 cue 故意改错 0.3 s 重跑做反证。" % (len(cue_types), len(ev_kinds)))
832
905
  warns.append(msg)
@@ -1010,6 +1083,11 @@ def report(film, *, cues_json=None, expect_duration=None, out_json=None, out_png
1010
1083
  exp = float(expect_duration)
1011
1084
  except Exception: # noqa: BLE001
1012
1085
  exp = None
1086
+ if exp is not None and not math.isfinite(exp):
1087
+ # nan 会让 abs(duration - nan) > 0.5 恒为 False —— 也就是"传了预期时长,校验静默失效"。
1088
+ # 这属于调用方写错,不当作通过:记 warning 说明这次没查,别让它假扮成 PASS。
1089
+ warnings.append("expect_duration 不是有限数字(%r),本次不做预期时长校验" % (expect_duration,))
1090
+ exp = None
1013
1091
  if exp is not None and abs(duration - exp) > 0.5:
1014
1092
  errors.append("时长 %.3fs 与预期 %.3fs 不符(差 %+.3fs,容差 ±0.5s)"
1015
1093
  % (duration, exp, duration - exp))
@@ -1026,7 +1104,10 @@ def report(film, *, cues_json=None, expect_duration=None, out_json=None, out_png
1026
1104
  warnings.extend(_static_warnings(drift, fps, blacks=blacks))
1027
1105
 
1028
1106
  rep = {"flicker": flicker, "black_windows": blacks, "luma": luma, "loudness": loud,
1029
- "sync": sync, "errors": errors, "warnings": warnings}
1107
+ "sync": sync, "errors": errors, "warnings": warnings,
1108
+ # sync_info:同步门禁到底查了多少(render.py:1172 一直在读 deep["sync_info"]["judged"],
1109
+ # 但 report() 以前从来不写这个键 —— 所以 QC 报告的 G9 永远显示"同步未判")。
1110
+ "sync_info": dict(sync_info)}
1030
1111
  if out_json:
1031
1112
  _write_json(rep, out_json)
1032
1113
  if out_png:
@@ -1302,9 +1383,12 @@ def _print_summary(rep, film, out_json=None, out_png=None):
1302
1383
  print(" 成片:", film)
1303
1384
  n_sup = sum(1 for w in (rep.get("warnings") or []) if "高分异常被判为运动" in str(w))
1304
1385
  n_static = sum(1 for w in (rep.get("warnings") or []) if "画面长时间静止" in str(w))
1305
- print(" 闪烁 %d(其中与已登记视觉事件重合 %d) / 空窗 %d / 同步超差 %d%s%s"
1386
+ si = rep.get("sync_info") or {}
1387
+ n_src = int(si.get("src_skipped") or 0)
1388
+ print(" 闪烁 %d(其中与已登记视觉事件重合 %d) / 空窗 %d / 同步超差 %d%s%s%s"
1306
1389
  % (len(fl), explained, len(rep.get("black_windows") or []),
1307
1390
  len(rep.get("sync") or []),
1391
+ (" / 跨基准未对照 %d" % n_src) if n_src else "",
1308
1392
  (" / 静止段 %d" % n_static) if n_static else "",
1309
1393
  (" / 高分异常待复核 %d" % n_sup) if n_sup else ""))
1310
1394
  print(" 亮度采样 %d 点 / 响度采样 %d 点"
@@ -1729,8 +1813,13 @@ def selftest() -> int:
1729
1813
  out_json=out_json, out_png=out_png)
1730
1814
  keys = {"flicker", "black_windows", "luma", "loudness", "sync",
1731
1815
  "errors", "warnings"}
1732
- if set(rep) != keys:
1733
- return False, "顶层字段 %s 不符 §4.5" % sorted(rep)
1816
+ # sync_info 是 v0.7.2 的**增量**字段(render.py 的 G9 从 deep["sync_info"]["judged"]
1817
+ # 读"同步到底判了几条",以前 report() 不写它 -> QC 报告永远显示"同步未判")。
1818
+ # 契约 §4.5 的 7 个键必须齐全,允许额外的只读统计键。
1819
+ if not keys.issubset(set(rep)):
1820
+ return False, "顶层字段 %s 缺 §4.5 键" % sorted(keys - set(rep))
1821
+ if not isinstance(rep.get("sync_info"), dict):
1822
+ return False, "sync_info 缺失或不是 dict:%r" % (rep.get("sync_info"),)
1734
1823
  if len(rep["flicker"]) != 1 or rep["flicker"][0].get("expected") is not False:
1735
1824
  return False, "flicker=%s" % (rep["flicker"],)
1736
1825
  if len(rep["black_windows"]) != 1:
@@ -1776,9 +1865,10 @@ def selftest() -> int:
1776
1865
  return False, "deep.png 缺失或过小"
1777
1866
  with open(out_json, encoding="utf-8") as fp:
1778
1867
  data = json.load(fp)
1779
- if set(data) != {"flicker", "black_windows", "luma", "loudness", "sync",
1780
- "errors", "warnings"}:
1781
- return False, "JSON 字段不符"
1868
+ need = {"flicker", "black_windows", "luma", "loudness", "sync",
1869
+ "errors", "warnings"}
1870
+ if not need.issubset(set(data)):
1871
+ return False, "JSON 字段不符(缺 %s)" % sorted(need - set(data))
1782
1872
  img = Image.open(out_png)
1783
1873
  img.load()
1784
1874
  if img.width < 1000 or img.height < 600: