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

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 (39) hide show
  1. package/CHANGELOG.md +951 -0
  2. package/README.md +51 -8
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/index.js +4 -1
  5. package/package.json +5 -3
  6. package/schemas/film.schema.json +162 -2
  7. package/skills/video-studio/SKILL.md +211 -11
  8. package/skills/video-studio/assets/golden_frames.json +65 -0
  9. package/skills/video-studio/assets/web/engine.js +63 -2
  10. package/skills/video-studio/assets/web/film.js +224 -101
  11. package/skills/video-studio/assets/web/index.html +4 -2
  12. package/skills/video-studio/assets/web/qa_text.js +36 -8
  13. package/skills/video-studio/references/audio-variation.md +4 -3
  14. package/skills/video-studio/references/audio.md +2 -2
  15. package/skills/video-studio/references/change-impact.md +1 -1
  16. package/skills/video-studio/references/quality-gates.md +25 -3
  17. package/skills/video-studio/references/style-packs.md +7 -3
  18. package/skills/video-studio/references/v08-quality.md +256 -0
  19. package/skills/video-studio/references/web-engine.md +19 -2
  20. package/skills/video-studio/references/word-timeline.md +147 -0
  21. package/skills/video-studio/requirements.txt +14 -0
  22. package/skills/video-studio/scripts/filmcheck.py +121 -0
  23. package/skills/video-studio/scripts/filmkit.py +521 -38
  24. package/skills/video-studio/scripts/gates.py +88 -4
  25. package/skills/video-studio/scripts/preflight.py +774 -0
  26. package/skills/video-studio/scripts/qa_frames.py +49 -8
  27. package/skills/video-studio/scripts/qaasr.py +1563 -0
  28. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  29. package/skills/video-studio/scripts/render.py +719 -32
  30. package/skills/video-studio/scripts/scorekit.py +16 -30
  31. package/skills/video-studio/scripts/selfcheck.py +274 -0
  32. package/skills/video-studio/scripts/sfxkit.py +130 -14
  33. package/skills/video-studio/scripts/stagewatch.py +19 -6
  34. package/skills/video-studio/scripts/stylekit.py +43 -12
  35. package/skills/video-studio/scripts/voicekit.py +15 -7
  36. package/skills/video-studio/scripts/webengine.py +1119 -72
  37. package/tools/ci_check.py +171 -31
  38. package/tools/estimate.py +130 -19
  39. package/tools/golden_frames.py +664 -0
@@ -334,7 +334,7 @@ THEMES["lo-fi"] = _theme(
334
334
  ("深夜", "学习", "效率", "专注", "写作", "整理", "笔记", "书", "日常", "房间"),
335
335
  # 冷调灰绿:和 paper(暖牛皮纸)在色相上分开,不靠"亮度差不多"硬撑
336
336
  dict(bg=(232, 238, 236), bg2=(210, 220, 216), surface=(244, 248, 246),
337
- ink=(44, 52, 50), muted=(86, 100, 96), dim=(112, 126, 122),
337
+ ink=(44, 52, 50), muted=(84, 98, 94), dim=(112, 126, 122),
338
338
  accent=(20, 86, 84), accent2=(104, 72, 96),
339
339
  good=(40, 100, 72), bad=(146, 52, 50), rule=(44, 52, 50)),
340
340
  dict(vignette=0.12, vignette_tint=(206, 200, 190), grain=7.0, grain_freq=0.24,
@@ -633,7 +633,11 @@ def score_styles(script):
633
633
  score += 1.0 if sid in ("cinema", "paper", "mono") else 0.0
634
634
  if prof["hook"] > 0 and prof["outro"] > 0:
635
635
  score += 0.5 if sid in ("tech-deep", "vivid", "epic") else 0.0
636
- score += (int(fp["style"][:4], 16) % 7) * 0.0001 # 指纹破平
636
+ # 指纹破平:必须把**风格 id** 混进哈希,否则这一项对所有行是同一个常数,
637
+ # 等于没破平(v0.8 深审实测:旧写法在"AI 报告"上 tech-deep / magazine 同分 12.0,
638
+ # 永远按 STYLE_IDS 顺序选 tech-deep)。幅度上限 0.0006 << 最小真实分差 0.5,
639
+ # 只在精确同分时起作用,且不改变"改错别字不换风格"(指纹只吃标题)。
640
+ score += (_digest("tiebreak", fp["style"], sid) % 7) * 0.0001
637
641
  rows.append((sid, round(score, 4), hits))
638
642
  rows.sort(key=lambda r: (-r[1], STYLE_IDS.index(r[0])))
639
643
  return rows
@@ -645,7 +649,12 @@ def pick_style(script):
645
649
  best_id, best_score, best_hits = rows[0]
646
650
  fp = fingerprint(script)
647
651
  if best_score >= 1.0:
648
- reason = ["关键词命中:%s" % "、".join(dict.fromkeys(best_hits))[:80]]
652
+ if best_hits:
653
+ reason = ["关键词命中:%s" % "、".join(dict.fromkeys(best_hits))[:80]]
654
+ else:
655
+ # 分数可以只由结构加分(items / 长句 / hook+outro)凑到 1.0 以上,
656
+ # 这时旧文案会写"关键词命中:"后面空一截 —— 等于向用户报告没发生的事。
657
+ reason = ["无关键词命中,按题材结构加分选中(items / 长句 / hook+outro)"]
649
658
  else:
650
659
  pool = list(STYLE_IDS)
651
660
  best_id = pool[int(fp["tempo"], 16) % len(pool)]
@@ -816,6 +825,15 @@ def resolve_script_style(script):
816
825
  # theme_overrides 走带校验的合并(颜色支持 "#rrggbb",非法值在这里就报错,
817
826
  # 不要等到 scenes.py 渲到第 300 帧才 int("#") 崩——见 apply_theme_overrides)
818
827
  theme = apply_theme_overrides(THEMES[pack["theme"]], vis.get("theme_overrides"))
828
+ # visual.layout 是"显式换整片版式":旧的实现只把它写进 chapter_layout,而
829
+ # PIL 路线渲染读的是 theme["layout"]、web 路线读 layout_default —— 两条路线都不看
830
+ # chapter_layout,于是这条文档化的覆盖等于没写(v0.8 深审实测:visual.layout=
831
+ # split-right 的帧与默认帧 sha256 完全相同)。这里补写 theme["layout"] 与 layout_default,
832
+ # 让两条路线都跟着走;theme_overrides 更具体,它显式写了 layout 时以它为准。
833
+ if explicit_layout and "layout" not in (vis.get("theme_overrides") or {}):
834
+ theme["layout"] = explicit_layout
835
+ if explicit_layout:
836
+ chap_layout = {ch: str(theme["layout"]) for ch in chapters}
819
837
 
820
838
  return {
821
839
  "version": 1,
@@ -831,7 +849,7 @@ def resolve_script_style(script):
831
849
  # 主题字典随计划一起返回:scenes.py 的 _scene_theme 优先用它(含 theme_overrides),
832
850
  # 也方便 render.py 直接传给 draw_subtitle/write_ass,不必再按 id 查一次。
833
851
  "theme": theme,
834
- "layout_default": layouts[0],
852
+ "layout_default": str(theme["layout"]) if explicit_layout else layouts[0],
835
853
  "chapter_layout": chap_layout,
836
854
  "music": {
837
855
  "style": music_style,
@@ -854,7 +872,8 @@ def resolve_script_style(script):
854
872
  # 七、门禁:主题体检(对比度 / 明暗 / 结构)
855
873
  # ============================================================================
856
874
  #
857
- # 阈值不是拍脑袋:以 v0.4 现行主题实测值当基线(ink 16.3 / muted 7.8 / accent 7.0)。
875
+ # 阈值不是拍脑袋:以 v0.4 现行主题实测值当基线(tech-deep:ink 16.3 / muted 7.8 / accent 7.0,
876
+ # 那是旧版按 bg/bg2 中点量的;v0.8 改成按两端最差值量后同一主题是 14.8 / 7.1 / 6.3,仍远高于线)。
858
877
 
859
878
  LINT_LIMITS = {
860
879
  "ink": 7.0, # 正文级(WCAG AAA 正文线)
@@ -868,18 +887,30 @@ LINT_LIMITS = {
868
887
  }
869
888
 
870
889
 
890
+ def bg_contrast(th, role):
891
+ """文字色对背景的**最差**对比度:bg/bg2 是上下两端的主渐变(scenes._bg 按行插值),
892
+ 任何一行文字都可能落在两端附近,所以取二者较小值,而不是看中点。
893
+
894
+ 为什么不能只看中点:lo-fi 的 muted(86,100,96) 对 bg 5.28、对 bg2 只有 4.42,
895
+ 中点平均 4.84 把 AA 4.5 的门槛"平均"过去了 —— 实测过(v0.8 深审),
896
+ 这一版把门槛改成端点最差值,并同时下调 lo-fi 的 muted 到 (84,98,94)
897
+ (bg 5.44 / bg2 4.56,两端都过)。
898
+ """
899
+ return min(contrast(th[role], th["bg"]), contrast(th[role], th["bg2"]))
900
+
901
+
871
902
  def theme_lint(th):
872
903
  """主题体检,返回问题列表(空 = 过)。坏主题在 load 阶段就拦,别等渲完 300 帧。"""
873
904
  if not isinstance(th, dict):
874
905
  return ["主题不是 dict:%r" % (th,)]
875
906
  bad = []
876
- bgs = midpoint(th["bg"], th["bg2"])
877
907
  for role, need in (("ink", LINT_LIMITS["ink"]), ("muted", LINT_LIMITS["muted"]),
878
908
  ("dim", LINT_LIMITS["dim"]), ("accent", LINT_LIMITS["accent"]),
879
909
  ("accent2", LINT_LIMITS["accent2"])):
880
- got = contrast(th[role], bgs)
910
+ got = bg_contrast(th, role)
881
911
  if got < need:
882
- bad.append("%s 对背景对比度 %.2f < %.1f" % (role, got, need))
912
+ bad.append("%s 对背景对比度 %.2f < %.1f(bg/bg2 两端取最差)"
913
+ % (role, got, need))
883
914
  for role in ("good", "bad"):
884
915
  got = contrast(th[role], th["surface"])
885
916
  if got < LINT_LIMITS[role]:
@@ -947,7 +978,6 @@ def blend_theme(a, b, p):
947
978
  def _describe(sid):
948
979
  pack = STYLES[sid]
949
980
  th = THEMES[pack["theme"]]
950
- bgs = midpoint(th["bg"], th["bg2"])
951
981
  lines = [
952
982
  "[%s] %s (theme=%s / layouts=%s)" % (sid, pack["name"], pack["theme"],
953
983
  ",".join(pack["layouts"])),
@@ -961,9 +991,10 @@ def _describe(sid):
961
991
  % (pack["pacing"]["lead"], pack["pacing"]["tail"], pack["pacing"]["min_dur"]),
962
992
  " palette : bg=%s bg2=%s ink=%s accent=%s"
963
993
  % (th["bg"], th["bg2"], th["ink"], th["accent"]),
964
- " contrast : ink %.2f / muted %.2f / dim %.2f / accent %.2f"
965
- % (contrast(th["ink"], bgs), contrast(th["muted"], bgs),
966
- contrast(th["dim"], bgs), contrast(th["accent"], bgs)),
994
+ # 与 theme_lint 同口径:bg/bg2 两端取最差(旧版打中点值,会掩盖端点不达标)
995
+ " contrast : ink %.2f / muted %.2f / dim %.2f / accent %.2f(bg/bg2 最差端)"
996
+ % (bg_contrast(th, "ink"), bg_contrast(th, "muted"),
997
+ bg_contrast(th, "dim"), bg_contrast(th, "accent")),
967
998
  " font : family=%s display=%spx hud=%s"
968
999
  % (th["family"], th["scale"]["display"][0], th["hud"]),
969
1000
  ]
@@ -207,8 +207,12 @@ except Exception: # noqa: BLE001 — stylekit 缺失时
207
207
  # ============================================================================
208
208
  # 三、小工具:参数解析/格式化、汉字计数、抖动
209
209
  # ============================================================================
210
- _PCT_RE = re.compile(r"^\s*([+-]?)(\d+)\s*%\s*$")
211
- _HZ_RE = re.compile(r"^\s*([+-]?)(\d+)\s*Hz\s*$")
210
+ # 允许小数("+4.5%"):edge-tts 只收整数百分比,所以解析后按 _round_half_away 取整。
211
+ # 以前正则只认整数,"+4.5%" 解析失败 → 静默回落 0 —— 而 0 与 "+0%" 在 spec_key 里
212
+ # 是**同一个 TTS 缓存键**,于是"把语速从 0 调到 4.5%"完全没生效,还复用了旧音频。
213
+ # 这是配置写对了却不生效里最难查的一类:没有报错、没有日志、听起来就是"没反应"。
214
+ _PCT_RE = re.compile(r"^\s*([+-]?)(\d+(?:\.\d+)?)\s*%\s*$")
215
+ _HZ_RE = re.compile(r"^\s*([+-]?)(\d+(?:\.\d+)?)\s*Hz\s*$")
212
216
 
213
217
 
214
218
  def _round_half_away(v):
@@ -218,24 +222,24 @@ def _round_half_away(v):
218
222
 
219
223
 
220
224
  def parse_pct(v, default=0):
221
- """'+4%' -> 4;'-3%' -> -3;数字原样取整;解析失败回落 default。"""
225
+ """'+4%' -> 4;'-3%' -> -3;'+4.5%' -> 5(取整);数字原样取整;解析失败回落 default。"""
222
226
  if isinstance(v, (int, float)) and not isinstance(v, bool):
223
227
  return int(_round_half_away(v))
224
228
  m = _PCT_RE.match(str(v if v is not None else ""))
225
229
  if not m:
226
230
  return int(default)
227
- n = int(m.group(2))
231
+ n = int(_round_half_away(float(m.group(2))))
228
232
  return -n if m.group(1) == "-" else n
229
233
 
230
234
 
231
235
  def parse_hz(v, default=0):
232
- """'+2Hz' -> 2;解析失败回落 default。"""
236
+ """'+2Hz' -> 2;'+2.5Hz' -> 3(取整);解析失败回落 default。"""
233
237
  if isinstance(v, (int, float)) and not isinstance(v, bool):
234
238
  return int(_round_half_away(v))
235
239
  m = _HZ_RE.match(str(v if v is not None else ""))
236
240
  if not m:
237
241
  return int(default)
238
- n = int(m.group(2))
242
+ n = int(_round_half_away(float(m.group(2))))
239
243
  return -n if m.group(1) == "-" else n
240
244
 
241
245
 
@@ -306,7 +310,11 @@ def _quantize(v, u):
306
310
  # ============================================================================
307
311
  # 四、文本规则(说什么):全部确定性,命中即返回理由
308
312
  # ============================================================================
309
- _QUOTE_OPEN = ('"', "\u201c", "\u300c", "\u300e", "\u300a")
313
+ # 引语起始符。**不含书名号 《(《)**:正文里出现"《设计模式》"是书名,不是引用某人在说话。
314
+ # 实测踩过:把 《 算进引语后,"今天我们聊《设计模式》"这句的 tone 被判成 quote,
315
+ # 进而触发 rule:quote:persona,把 persona 从 narrator_male 换成 narrator_female ——
316
+ # 旁白中途换人,听感像换了主播(而这句根本没有人引语)。
317
+ _QUOTE_OPEN = ('"', "\u201c", "\u300c", "\u300e")
310
318
  _QUOTE_WORDS = ("他说", "她说", "有人问", "所谓", "据称")
311
319
  _NUM_UNITS = ("%", "万", "亿", "年", "倍")
312
320
  _TURN_WORDS = ("但是", "然而", "不过", "其实", "问题是")