@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.
Files changed (38) hide show
  1. package/README.md +93 -18
  2. package/docs/styles/demo-results.json +76 -0
  3. package/docs/styles/sheet-8-styles.png +0 -0
  4. package/docs/styles/sheet-ai.png +0 -0
  5. package/docs/styles/sheet-craft.png +0 -0
  6. package/docs/styles/sheet-night.png +0 -0
  7. package/index.js +20 -7
  8. package/package.json +13 -3
  9. package/schemas/film.schema.json +599 -0
  10. package/skills/video-studio/SKILL.md +252 -28
  11. package/skills/video-studio/assets/film.json +78 -24
  12. package/skills/video-studio/assets/scenes.py +1005 -42
  13. package/skills/video-studio/assets/web/engine.js +18 -1
  14. package/skills/video-studio/assets/web/film.js +29 -4
  15. package/skills/video-studio/assets/web/index.html +5 -0
  16. package/skills/video-studio/references/audio-variation.md +158 -0
  17. package/skills/video-studio/references/audio.md +96 -6
  18. package/skills/video-studio/references/change-impact.md +54 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/style-packs.md +178 -0
  21. package/skills/video-studio/references/web-engine.md +1 -1
  22. package/skills/video-studio/requirements.txt +13 -1
  23. package/skills/video-studio/scripts/filmcheck.py +725 -0
  24. package/skills/video-studio/scripts/filmkit.py +953 -76
  25. package/skills/video-studio/scripts/gates.py +509 -0
  26. package/skills/video-studio/scripts/qa_frames.py +109 -19
  27. package/skills/video-studio/scripts/render.py +942 -45
  28. package/skills/video-studio/scripts/scorekit.py +1887 -27
  29. package/skills/video-studio/scripts/selfcheck.py +648 -2
  30. package/skills/video-studio/scripts/sfxkit.py +132 -30
  31. package/skills/video-studio/scripts/stagewatch.py +405 -0
  32. package/skills/video-studio/scripts/stylekit.py +1012 -0
  33. package/skills/video-studio/scripts/voicekit.py +880 -0
  34. package/skills/video-studio/scripts/webengine.py +68 -14
  35. package/tools/audio_similarity.py +279 -0
  36. package/tools/ci_check.py +450 -0
  37. package/tools/estimate.py +657 -0
  38. 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
- 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: