@dsh-gis/dsh-video-studio-skill 0.4.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 +93 -18
- package/docs/styles/demo-results.json +76 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/index.js +20 -7
- package/package.json +13 -3
- package/schemas/film.schema.json +599 -0
- package/skills/video-studio/SKILL.md +252 -28
- package/skills/video-studio/assets/film.json +78 -24
- package/skills/video-studio/assets/scenes.py +1005 -42
- 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 +96 -6
- package/skills/video-studio/references/change-impact.md +54 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/style-packs.md +178 -0
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/requirements.txt +13 -1
- package/skills/video-studio/scripts/filmcheck.py +725 -0
- package/skills/video-studio/scripts/filmkit.py +953 -76
- package/skills/video-studio/scripts/gates.py +509 -0
- package/skills/video-studio/scripts/qa_frames.py +109 -19
- package/skills/video-studio/scripts/render.py +942 -45
- package/skills/video-studio/scripts/scorekit.py +1887 -27
- package/skills/video-studio/scripts/selfcheck.py +648 -2
- package/skills/video-studio/scripts/sfxkit.py +132 -30
- package/skills/video-studio/scripts/stagewatch.py +405 -0
- package/skills/video-studio/scripts/stylekit.py +1012 -0
- package/skills/video-studio/scripts/voicekit.py +880 -0
- package/skills/video-studio/scripts/webengine.py +68 -14
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +450 -0
- package/tools/estimate.py +657 -0
- package/tools/selftest_noscipy.py +70 -0
|
@@ -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:
|