@dsh-gis/dsh-video-studio-skill 1.0.2 → 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 (51) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +84 -108
  3. package/THIRD_PARTY_NOTICES.md +2 -2
  4. package/package.json +1 -1
  5. package/schemas/design.schema.json +5 -5
  6. package/schemas/film.schema.json +31 -31
  7. package/skills/video-studio/SKILL.md +168 -268
  8. package/skills/video-studio/assets/film.json +2 -3
  9. package/skills/video-studio/assets/golden_frames.json +11 -11
  10. package/skills/video-studio/assets/scenes.py +92 -49
  11. package/skills/video-studio/references/{style-packs.md → appearance.md} +17 -15
  12. package/skills/video-studio/references/audio-variation.md +73 -96
  13. package/skills/video-studio/references/audio.md +40 -44
  14. package/skills/video-studio/references/change-impact.md +2 -2
  15. package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +9 -11
  16. package/skills/video-studio/references/frame-qa.md +47 -15
  17. package/skills/video-studio/references/pipeline.md +4 -4
  18. package/skills/video-studio/references/pitfalls.md +22 -3
  19. package/skills/video-studio/references/proposal.md +19 -36
  20. package/skills/video-studio/references/quality-gates.md +11 -11
  21. package/skills/video-studio/references/scene-authoring.md +2 -2
  22. package/skills/video-studio/references/scene-grammar.md +17 -24
  23. package/skills/video-studio/references/sfx-design.md +7 -5
  24. package/skills/video-studio/references/web-engine.md +7 -7
  25. package/skills/video-studio/references/word-timeline.md +7 -7
  26. package/skills/video-studio/requirements.txt +9 -13
  27. package/skills/video-studio/scripts/designkit.py +84 -89
  28. package/skills/video-studio/scripts/filmcheck.py +19 -19
  29. package/skills/video-studio/scripts/filmkit.py +2949 -2948
  30. package/skills/video-studio/scripts/gates.py +4 -4
  31. package/skills/video-studio/scripts/grammar.py +10 -10
  32. package/skills/video-studio/scripts/musicgen.py +3 -4
  33. package/skills/video-studio/scripts/preflight.py +5 -5
  34. package/skills/video-studio/scripts/previewkit.py +11 -12
  35. package/skills/video-studio/scripts/propose.py +11 -11
  36. package/skills/video-studio/scripts/qa_frames.py +34 -35
  37. package/skills/video-studio/scripts/qaasr.py +26 -7
  38. package/skills/video-studio/scripts/qavmaf.py +19 -19
  39. package/skills/video-studio/scripts/render.py +137 -141
  40. package/skills/video-studio/scripts/scorekit.py +21 -23
  41. package/skills/video-studio/scripts/selfcheck.py +208 -135
  42. package/skills/video-studio/scripts/sfxkit.py +27 -26
  43. package/skills/video-studio/scripts/stagewatch.py +4 -4
  44. package/skills/video-studio/scripts/stylekit.py +77 -282
  45. package/skills/video-studio/scripts/voicekit.py +43 -44
  46. package/skills/video-studio/scripts/webengine.py +62 -64
  47. package/tools/audio_similarity.py +12 -12
  48. package/tools/ci_check.py +16 -16
  49. package/tools/estimate.py +24 -25
  50. package/tools/golden_frames.py +17 -18
  51. package/tools/silhouette_qa.py +13 -14
@@ -1,7 +1,7 @@
1
1
  # -*- coding: utf-8 -*-
2
- """qaasr.py — ASR 回读校验(v0.8.0)。
2
+ """qaasr.py — ASR 回读校验。
3
3
 
4
- v0.7 及以前的质检只看"容器 / 响度 / 帧 / 音画同步",都建立在一个假设上:
4
+ 容器 / 响度 / 帧 / 音画同步这几层质检,都建立在一个假设上:
5
5
  **成片音轨里念的就是剧本那几句话**。可是 TTS 缓存串了、换声线时漏了某句、
6
6
  混音把某一段压没了、重新渲了但音轨是旧的——这些从容器和波形上都看不出来。
7
7
  本模块把成片音轨转回文字,和剧本逐句对账:
@@ -66,7 +66,7 @@ tiny 只适合快速冒烟(错字多、时间戳只到整秒)。默认 model
66
66
  """
67
67
  from __future__ import annotations
68
68
 
69
- __version__ = "0.8.0"
69
+ __version__ = "1.0.4"
70
70
 
71
71
  import argparse
72
72
  import json
@@ -101,7 +101,7 @@ AUDIO_SR = 16000
101
101
  OFFSET_WARN_S = 0.35
102
102
  # 召回率的 pass / fail 线。**权威值在 render.py**(QA_ASR_RECALL_PASS / QA_ASR_RECALL_WARN,
103
103
  # G12 门禁从这里读),本模块只是把默认值对齐,避免"同一部片、CLI 判 fail、门禁判 pass":
104
- # 同一事实只能有一个权威值(CHANGELOG v0.4 D 节)。
104
+ # 同一事实只能有一个权威值。
105
105
  # 为什么是 0.85 / 0.72 而不是 0.95 / 0.90:中文旁白实测(模板片,faster-whisper small)
106
106
  # 干净成片召回只有 0.81–0.83 —— 中英混排专名("DeepSeek 4.1 Flash"→DIXX 变体)是 ASR 的
107
107
  # 正常误差,不是片子坏了;低于 0.72 才说明"整句丢了 / 音轨跟剧本不是一套"。
@@ -821,9 +821,13 @@ def _judge(scenes, segments, *, recall_pass=QA_RECALL_PASS, recall_warn=QA_RECAL
821
821
  assigned = _assign_text(scenes, segments)
822
822
  per_line, missing, offsets, offset_ref = [], [], {}, {}
823
823
  tot_ref, tot_hit = 0, 0
824
+ lost_counter = Counter() # 全片"ref 有、ASR 没对上"的字符
824
825
  for sc, got in zip(scenes, assigned):
825
826
  ref = _norm_text(sc.get("text"))
826
827
  hyp = _norm_text(got["text"])
828
+ lost = Counter(ref) - Counter(hyp) if ref else Counter()
829
+ if lost:
830
+ lost_counter.update(lost.elements())
827
831
  hit = sum((Counter(ref) & Counter(hyp)).values()) if ref else 0
828
832
  r = (hit / float(len(ref))) if ref else 1.0
829
833
  tot_ref += len(ref)
@@ -833,6 +837,8 @@ def _judge(scenes, segments, *, recall_pass=QA_RECALL_PASS, recall_warn=QA_RECAL
833
837
  "end": round(float(sc["end"]), 3), "ref_chars": len(ref),
834
838
  "asr_chars": len(hyp), "hit_chars": hit, "recall": round(r, 4),
835
839
  "asr_text": got["text"][:200],
840
+ # 单段掉分定位:这一段里"剧本有、ASR 没对上"的字(最多 6 个)
841
+ "lost": ["%s×%d" % (ch, n) for ch, n in lost.most_common(6)] if lost else [],
836
842
  })
837
843
  if ref and r < MISS_LINE_RECALL:
838
844
  missing.append(sc["id"])
@@ -866,8 +872,21 @@ def _judge(scenes, segments, *, recall_pass=QA_RECALL_PASS, recall_warn=QA_RECAL
866
872
  % (recall, recall_warn, tot_ref, tot_hit))
867
873
  status = "fail"
868
874
  elif recall < recall_pass - 1e-12:
875
+ # 只报"召回 0.813"没法修:实测掉分几乎全在中英混排专名("DeepSeek 4.1 Flash"
876
+ # 被听成 DIXX 之类)。这里把"剧本里有、成片里没对上"的字符按次数排出来,
877
+ # 让 agent 知道该改哪几个词(改读法 / 加同音别名 / 换掉 ASR 不认的写法)。
869
878
  warnings.append("ASR 回读字符召回 %.3f 低于 pass 线 %.2f(但高于 fail 线 %.2f):"
870
879
  "pass 但记 warning,别当完全没问题" % (recall, recall_pass, recall_warn))
880
+ if lost_counter:
881
+ tips = "、".join("%s×%d" % (ch, n) for ch, n in lost_counter.most_common(12))
882
+ warnings.append("成片里没对上的字(ref 有 / ASR 无,按次数):%s —— 多是专名读法,"
883
+ "改剧本写法或在 film.json 的 qa.asr 放宽" % tips)
884
+ worst = sorted((p for p in per_line if p.get("lost")),
885
+ key=lambda p: float(p.get("recall") or 0.0))[:2]
886
+ for p in worst:
887
+ warnings.append("最差段落 %s(召回 %.2f,%d/%d 字):没对上 %s;ASR 实际读到 %r"
888
+ % (p["id"], float(p["recall"]), p["hit_chars"], p["ref_chars"],
889
+ "、".join(p["lost"]), p["asr_text"][:50]))
871
890
  status = "pass"
872
891
  else:
873
892
  status = "pass"
@@ -879,6 +898,7 @@ def _judge(scenes, segments, *, recall_pass=QA_RECALL_PASS, recall_warn=QA_RECAL
879
898
  detail += ";最大偏移 %s %+.2fs" % (worst[0], float(worst[1]))
880
899
  return {
881
900
  "recall": round(recall, 4), "ref_chars": tot_ref, "hit_chars": tot_hit,
901
+ "lost_top": [{"ch": ch, "n": n} for ch, n in lost_counter.most_common(20)],
882
902
  "per_line": per_line, "missing_lines": missing,
883
903
  "offsets": offsets, "offset_ref": offset_ref, "offset_warn_s": float(offset_warn),
884
904
  "gate": {"id": "asr", "title": "ASR 回读(原稿 vs 成片旁白)",
@@ -1145,7 +1165,7 @@ def report(film_mp4, tl, script=None, *, model_size="small", lang="zh", out_json
1145
1165
  rep["audio_s"] = audio_info.get("duration") # 前面探测过,不重复跑 ffprobe
1146
1166
  judged = _judge(scenes, rep["asr_segments"], recall_pass=recall_pass,
1147
1167
  recall_warn=recall_warn, offset_warn=offset_warn)
1148
- for key in ("recall", "ref_chars", "hit_chars", "per_line", "missing_lines",
1168
+ for key in ("recall", "ref_chars", "hit_chars", "lost_top", "per_line", "missing_lines",
1149
1169
  "offsets", "offset_ref", "gate", "warnings", "errors"):
1150
1170
  rep[key] = judged[key]
1151
1171
  if asr.get("parse_skipped"):
@@ -1491,8 +1511,7 @@ def _utf8_stdout() -> None:
1491
1511
  def main(argv=None) -> int:
1492
1512
  ap = argparse.ArgumentParser(
1493
1513
  prog="qaasr.py",
1494
- description="ASR 回读校验:把成片音轨转回文字,与 timeline/剧本做字符级召回与偏移检查"
1495
- "(v0.8.0)")
1514
+ description="ASR 回读校验:把成片音轨转回文字,与 timeline/剧本做字符级召回与偏移检查")
1496
1515
  ap.add_argument("film", nargs="?", help="成片路径(有音轨即可,视频轨不参与)")
1497
1516
  ap.add_argument("--timeline", default=None,
1498
1517
  help="work/timeline.json(scenes[].id/start/end,可选 voice_start/voice_end/text)")
@@ -1,5 +1,5 @@
1
1
  # -*- coding: utf-8 -*-
2
- """qavmaf.py — 全片 VMAF/SSIM 画质门禁(v0.8.0)。
2
+ """qavmaf.py — 全片 VMAF/SSIM 画质门禁。
3
3
 
4
4
  为什么要有它
5
5
  ------------
@@ -83,7 +83,7 @@ report(film_mp4, tl, scenes_mod, script, *, mode="full", out_json=None,
83
83
  · 宽高比不是 16:9 横屏(本项目只做 16:9 缩放输出);
84
84
  · 成片尺寸与 timeline.video 声明的交付尺寸不一致(顶层规格被改动);
85
85
  · 读不出成片分辨率。
86
- - v0.8 的"下采样交付"是合法配置:内部构图恒 1920x1080,编码时 lanczos
86
+ - "下采样交付"是合法配置:内部构图恒 1920x1080,编码时 lanczos
87
87
  缩到 1280x720 / 960x540。此时本模块**在成片分辨率下比较**——参考流在
88
88
  ffmpeg 滤镜图里用 scale=w:h:flags=lanczos 缩到成片分辨率(与 FrameSink
89
89
  编码时同口径,不用 PIL resize);返回 dict 的 ref_scale 记录映射关系。
@@ -108,7 +108,7 @@ CLI
108
108
  """
109
109
  from __future__ import annotations
110
110
 
111
- __version__ = "0.8.0"
111
+ __version__ = "1.0.4"
112
112
 
113
113
  import argparse
114
114
  import importlib.util
@@ -451,7 +451,7 @@ def _as_script(script) -> dict:
451
451
 
452
452
  # ---------------------------------------------------------------- 参考帧管线(复刻 render.py)
453
453
  def _looks_legacy(script) -> bool:
454
- """有没有"风格"信息:没有就是 v0.4 legacy 外观(与 stylekit 的判据一致)。"""
454
+ """有没有"风格"信息:没有就是 legacy 外观(与 stylekit 的判据一致)。"""
455
455
  vis = script.get("visual") if isinstance(script.get("visual"), dict) else {}
456
456
  mus = script.get("music") if isinstance(script.get("music"), dict) else {}
457
457
  return ("style" not in script) and not vis.get("theme") and not mus.get("style")
@@ -460,9 +460,9 @@ def _looks_legacy(script) -> bool:
460
460
  def _style_plan(script) -> dict:
461
461
  """解析剧本风格计划:**design 优先**,其次 script["_style"],最后 stylekit。
462
462
 
463
- 为什么必须先看 design:v1.0 之后"这一版长什么样"由 work/design.json 决定,
463
+ 为什么必须先看 design:"这一版长什么样"由 work/design.json 决定,
464
464
  而 qavmaf 是**独立进程**——它拿到的 script 里没有 render 塞进去的 _style。
465
- 早先这里直接调 stylekit,于是 design 路径的片子会被解析成 tech-deep(默认风格),
465
+ 直接调 stylekit 会把 design 路径的片子解析成 tech-deep(默认风格),
466
466
  参考帧用一套主题、成片用另一套:实测 VMAF 1.50、同源抽查 MAE=0.0384。
467
467
  这不是"画质劣化",是**参考帧根本不是同一部片**。
468
468
  """
@@ -515,7 +515,7 @@ ASPECT_TOL = 0.01 # 宽高比容差(与 filmkit.video_plan 一
515
515
  def _declared_out_size(tl, script):
516
516
  """交付规格:timeline.video(render.py 写出的快照)优先,其次剧本里的 video 块。
517
517
 
518
- 返回 (out_w, out_h);未声明时返回 None(老 timeline 升级前生成,行为同 v0.7)。
518
+ 返回 (out_w, out_h);未声明时返回 None(老 timeline 没有 video 块时的行为)。
519
519
  """
520
520
  for src in ((tl or {}).get("video"), (script or {}).get("video")):
521
521
  if not isinstance(src, dict):
@@ -538,7 +538,7 @@ def _judge_sizes(info, tl, script, comp_w, comp_h):
538
538
  "scale" 成片是 16:9 下采样交付,参考流在 ffmpeg 滤镜图里 lanczos 缩到成片尺寸
539
539
  "fail" 结构性不符:上采样 / 非 16:9 / 读不出尺寸 / 与声明交付尺寸不一致
540
540
 
541
- v0.8 的合法配置是"内部构图恒 1920x1080,编码时 lanczos 缩到 1280x720/960x540"。
541
+ 合法配置是"内部构图恒 1920x1080,编码时 lanczos 缩到 1280x720/960x540"。
542
542
  上采样不是合法交付(只会把 1080p 包装成更高分辨率),这里明确挡住。
543
543
  """
544
544
  fw, fh = info.get("width"), info.get("height")
@@ -605,7 +605,7 @@ def _prepare_script(script, tl):
605
605
 
606
606
 
607
607
  def _sub_kw_for(script) -> dict:
608
- """从已解析的风格计划里取字幕样式参数(v1.0.2,与 render.py:_sub_kw 同口径)。
608
+ """从已解析的风格计划里取字幕样式参数(与 render.py:_sub_kw 同口径)。
609
609
 
610
610
  ⚠ 为什么参考帧必须传这些参数(实测的**误杀好片**缺陷):
611
611
  render.py 的 frames / stills 阶段会传 design 的 visual.subtitle(size/align/
@@ -629,7 +629,7 @@ def _sub_kw_for(script) -> dict:
629
629
 
630
630
 
631
631
  def _draw_subtitle_like_render(frame, text, theme, words, kw=None):
632
- """烧字幕,与 render.py 同口径(v0.8 带 words 词边界折行,v1.0.2 带设计字幕样式)。
632
+ """烧字幕,与 render.py 同口径(带 words 词边界折行与设计字幕样式)。
633
633
 
634
634
  ⚠ 参考帧不传 words 也是**会误杀好片**的缺陷(实测):
635
635
  render.py 的 frames / stills 阶段调 draw_subtitle 时总会传 words,
@@ -678,7 +678,7 @@ def _draw_ref_frame(scenes, scenes_mod, script, theme, fi, fps, total):
678
678
  words = fn(scenes, t, hold) if fn is not None else None
679
679
  except Exception: # noqa: BLE001 —— 词表异常不该让参考帧渲不出来
680
680
  words = None
681
- # 字幕样式也必须与主渲染路径同源(v1.0.2;见 _sub_kw_for 的实测说明)
681
+ # 字幕样式也必须与主渲染路径同源(见 _sub_kw_for 的实测说明)
682
682
  frame = _draw_subtitle_like_render(frame, text, theme, words,
683
683
  kw=_sub_kw_for(script))
684
684
  fade = 1.0
@@ -705,7 +705,7 @@ def _build_filtergraph(fps, step, threads, has_ssim, vmaf_log, ssim_log, dist_fp
705
705
  libvmaf/ssim 的 framesync 会按"最近的下界时间戳"凑对,容易错帧。
706
706
  - 抽样(step>1)只对成片做 select,参考侧本来只喂抽中的帧,两边帧数天然相等。
707
707
  - split=2:**只渲染一遍**参考帧,同时喂 VMAF 与 SSIM 两个滤镜。
708
- - ref_scale:v0.8 下采样交付时,把参考流用 lanczos 缩到成片分辨率再比——
708
+ - ref_scale:下采样交付时,把参考流用 lanczos 缩到成片分辨率再比——
709
709
  与 filmkit.FrameSink 编码时的 -vf scale=out_w:out_h:flags=lanczos 同口径
710
710
  (不在 PIL 侧缩,避免两套插值口径混用)。
711
711
  """
@@ -923,7 +923,7 @@ def report(film_mp4, tl, scenes_mod, script, *, mode="full", out_json=None,
923
923
  warnings.append("ffmpeg 构建不含 ssim 滤镜,本次只算 VMAF(SSIM 记为 skipped)")
924
924
  rep["ffmpeg"] = ff
925
925
 
926
- # ---- 3. 成片探测 + 结构性校验(含 v0.8 下采样交付) ----
926
+ # ---- 3. 成片探测 + 结构性校验(含下采样交付) ----
927
927
  info = _probe_film(film)
928
928
  rep["film_info"] = info
929
929
  W, H = int(getattr(_K, "W", 1920)), int(getattr(_K, "H", 1080))
@@ -1284,7 +1284,7 @@ def draw_frame(scene, t_local, dur, t_global, script, frame):
1284
1284
  fp.write(scenes_src)
1285
1285
  script = {"title": "qavmaf selftest", "lines": [
1286
1286
  {"id": "l01", "text": "一", "sub": "自检"}]}
1287
- # v1.0.1 起没有内置风格表:合成剧本也必须自带一份**现场生成**的设计,
1287
+ # 没有内置风格表:合成剧本也必须自带一份**现场生成**的设计,
1288
1288
  # 否则 _style_plan() 解析不出主题("不写 style 就走老外观"的兼容路径已移除),
1289
1289
  # 参考帧无法渲染。这里用 designkit 现场采样(seed 固定 = 可复现)、
1290
1290
  # design_to_plan 与 render 同一口径,**不引入任何写死主题/模板**。
@@ -1293,15 +1293,15 @@ def draw_frame(scene, t_local, dur, t_global, script, frame):
1293
1293
  _design = _DK.sample_designs({"topic": "qavmaf selftest"}, n=1, seed=1)[0]
1294
1294
  script["_style"] = _DK.design_to_plan(_design)
1295
1295
  except Exception as e: # noqa: BLE001 —— 缺 designkit 也要给出可读的 FAIL
1296
- print("[FAIL] 自检需要 designkit.py 现场生成设计(v1.0.1 无内置风格):%s" % e)
1296
+ print("[FAIL] 自检需要 designkit.py 现场生成设计(无内置风格):%s" % e)
1297
1297
  return 1
1298
1298
  scenes = [dict(script["lines"][0], start=0.0, end=n / float(fps),
1299
1299
  dur=n / float(fps), voice_start=0.0, voice_end=n / float(fps))]
1300
1300
  tl = {"total": n / float(fps), "fps": fps, "frames": n, "scenes": scenes,
1301
1301
  "video": {"out_w": W, "out_h": H, "scale": False}}
1302
- # v0.8 下采样交付:内部构图仍 1920x1080,交付 1280x720(编码时 lanczos 缩)
1302
+ # 下采样交付:内部构图仍 1920x1080,交付 1280x720(编码时 lanczos 缩)
1303
1303
  tl720 = dict(tl, video={"out_w": 1280, "out_h": 720, "scale": True})
1304
- # 老 timeline(v0.7 及以前没有 video 块):只按"构图同尺寸"校验
1304
+ # 老 timeline(没有 video 块):只按"构图同尺寸"校验
1305
1305
  tl_legacy = {k: v for k, v in tl.items() if k != "video"}
1306
1306
  # 非法:声明 2560x1440 输出(内部构图只有 1080p,等于上采样)
1307
1307
  tl_up = dict(tl, video={"out_w": 2560, "out_h": 1440, "scale": True})
@@ -1376,7 +1376,7 @@ def draw_frame(scene, t_local, dur, t_global, script, frame):
1376
1376
  return ok and any_w, "frames_compared=%s" % rep["frames_compared"]
1377
1377
 
1378
1378
  def c_downscale_pass():
1379
- # v0.8 合法配置:1920x1080 内部构图 -> 1280x720 交付,必须 pass;
1379
+ # 合法配置:1920x1080 内部构图 -> 1280x720 交付,必须 pass;
1380
1380
  # 且与 1080p 同规格成片的 VMAF 差 < 3 分(阈值可比)
1381
1381
  rep = report(film_720, tl720, mod, script, mode="full", log=None)
1382
1382
  v1080 = report(film_ok, mode="full", log=None, **base)["vmaf"]["score"]
@@ -1454,7 +1454,7 @@ def draw_frame(scene, t_local, dur, t_global, script, frame):
1454
1454
  def main(argv=None) -> int:
1455
1455
  ap = argparse.ArgumentParser(
1456
1456
  prog="qavmaf.py",
1457
- description="全片 VMAF/SSIM 画质门禁:参考帧由场景函数实时渲染(v0.8.0)")
1457
+ description="全片 VMAF/SSIM 画质门禁:参考帧由场景函数实时渲染")
1458
1458
  ap.add_argument("--film", default=None, help="成片路径(mp4)")
1459
1459
  ap.add_argument("--timeline", default=None, help="work/timeline.json")
1460
1460
  ap.add_argument("--scenes", default=None, help="scenes.py(含 draw_frame)")