@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.
- package/README.md +41 -16
- package/index.js +20 -7
- package/package.json +4 -2
- package/schemas/film.schema.json +75 -4
- package/skills/video-studio/SKILL.md +95 -23
- package/skills/video-studio/assets/film.json +7 -14
- package/skills/video-studio/assets/scenes.py +115 -10
- package/skills/video-studio/assets/web/engine.js +18 -1
- package/skills/video-studio/assets/web/film.js +29 -4
- package/skills/video-studio/assets/web/index.html +5 -0
- package/skills/video-studio/references/audio-variation.md +158 -0
- package/skills/video-studio/references/audio.md +67 -5
- package/skills/video-studio/references/change-impact.md +1 -1
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/requirements.txt +2 -1
- package/skills/video-studio/scripts/filmcheck.py +81 -12
- package/skills/video-studio/scripts/filmkit.py +271 -18
- package/skills/video-studio/scripts/gates.py +75 -12
- package/skills/video-studio/scripts/qa_frames.py +109 -19
- package/skills/video-studio/scripts/render.py +420 -51
- package/skills/video-studio/scripts/scorekit.py +289 -46
- package/skills/video-studio/scripts/selfcheck.py +302 -2
- package/skills/video-studio/scripts/sfxkit.py +132 -30
- package/skills/video-studio/scripts/stagewatch.py +141 -31
- package/skills/video-studio/scripts/stylekit.py +94 -6
- package/skills/video-studio/scripts/voicekit.py +8 -0
- package/skills/video-studio/scripts/webengine.py +68 -14
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +145 -34
- package/tools/estimate.py +133 -54
- package/tools/selftest_noscipy.py +70 -0
|
@@ -54,13 +54,41 @@ def _sha1_file(path):
|
|
|
54
54
|
return "missing"
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
|
|
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":
|
|
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.
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1733
|
-
|
|
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
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
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:
|