@dsh-gis/dsh-video-studio-skill 0.6.0 → 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 (43) hide show
  1. package/CHANGELOG.md +951 -0
  2. package/README.md +89 -21
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/index.js +23 -7
  5. package/package.json +7 -3
  6. package/schemas/film.schema.json +235 -4
  7. package/skills/video-studio/SKILL.md +299 -27
  8. package/skills/video-studio/assets/film.json +7 -14
  9. package/skills/video-studio/assets/golden_frames.json +65 -0
  10. package/skills/video-studio/assets/scenes.py +115 -10
  11. package/skills/video-studio/assets/web/engine.js +81 -3
  12. package/skills/video-studio/assets/web/film.js +249 -101
  13. package/skills/video-studio/assets/web/index.html +9 -2
  14. package/skills/video-studio/assets/web/qa_text.js +36 -8
  15. package/skills/video-studio/references/audio-variation.md +159 -0
  16. package/skills/video-studio/references/audio.md +67 -5
  17. package/skills/video-studio/references/change-impact.md +2 -2
  18. package/skills/video-studio/references/quality-gates.md +25 -3
  19. package/skills/video-studio/references/style-packs.md +7 -3
  20. package/skills/video-studio/references/v08-quality.md +256 -0
  21. package/skills/video-studio/references/web-engine.md +19 -2
  22. package/skills/video-studio/references/word-timeline.md +147 -0
  23. package/skills/video-studio/requirements.txt +16 -1
  24. package/skills/video-studio/scripts/filmcheck.py +202 -12
  25. package/skills/video-studio/scripts/filmkit.py +792 -56
  26. package/skills/video-studio/scripts/gates.py +161 -14
  27. package/skills/video-studio/scripts/preflight.py +774 -0
  28. package/skills/video-studio/scripts/qa_frames.py +154 -23
  29. package/skills/video-studio/scripts/qaasr.py +1563 -0
  30. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  31. package/skills/video-studio/scripts/render.py +1134 -78
  32. package/skills/video-studio/scripts/scorekit.py +305 -76
  33. package/skills/video-studio/scripts/selfcheck.py +576 -2
  34. package/skills/video-studio/scripts/sfxkit.py +262 -44
  35. package/skills/video-studio/scripts/stagewatch.py +159 -36
  36. package/skills/video-studio/scripts/stylekit.py +137 -18
  37. package/skills/video-studio/scripts/voicekit.py +23 -7
  38. package/skills/video-studio/scripts/webengine.py +1186 -85
  39. package/tools/audio_similarity.py +279 -0
  40. package/tools/ci_check.py +310 -59
  41. package/tools/estimate.py +251 -61
  42. package/tools/golden_frames.py +664 -0
  43. package/tools/selftest_noscipy.py +70 -0
@@ -29,6 +29,7 @@ v0.5.0 画风主题化
29
29
  """
30
30
  from __future__ import annotations
31
31
 
32
+ import hashlib
32
33
  import math
33
34
  import os
34
35
  import sys
@@ -1055,7 +1056,57 @@ def draw_frame(scene, t_local, dur, t_global, script, frame):
1055
1056
  return fn(scene, t_local, dur, t_global, script, frame, plan, th)
1056
1057
 
1057
1058
 
1058
- # ---------------- 提示点(v0.3.0,可选) ----------------
1059
+ # ---------------- 提示点(v0.3.0 起可选;v0.7 按 kind 与内容生成) ----------------
1060
+ def _sha1_int(*parts):
1061
+ """把若干文本片段拼起来取 sha1 前 8 位(提示点"内容派生"的稳定来源)。"""
1062
+ raw = "|".join(str(p) for p in parts).encode("utf-8")
1063
+ return int(hashlib.sha1(raw).hexdigest()[:8], 16)
1064
+
1065
+
1066
+ def _item_pitch(text, base=1760.0):
1067
+ """条目文本 -> ui_blip 音高(sha1 派生,不用序号)。
1068
+
1069
+ v0.7 契约 §3.4:body 条目的 pitch 必须由**条目文本**派生,不再按序号
1070
+ (旧实现 1760+110*i 每片都一样)——同一句文案换到别的片子里仍是同一个音高,
1071
+ 不同文案落到 -5~+7 半音的不同音级;万一两条碰撞,_dedupe_segment 会再升半音。
1072
+ """
1073
+ off = _sha1_int("pitch", text) % 13 - 5
1074
+ return round(float(base) * 2.0 ** (off / 12.0), 2)
1075
+
1076
+
1077
+ def _cue_gain_decay(gain, repeat):
1078
+ """同段内同类型 cue 第 repeat 次重复(0 起)时的增益:每次再低 2 dB(-2/-4/-6/…)。
1079
+
1080
+ v0.7 契约 §3.4:不许再出现"每段都一模一样的三连击"——同类型重复必须**严格递减**
1081
+ (第 2 次 -2、第 3 次 -4、第 4 次 -6……),且 pitch 必须不同。
1082
+ """
1083
+ return round(float(gain) - 2.0 * int(repeat), 2)
1084
+
1085
+
1086
+ def _dedupe_segment(seg):
1087
+ """同一段内:同类型重复的 gain 递减、pitch 去重(保证"pitch 必须不同")。
1088
+
1089
+ 正文条目多时 sha1 可能把两条吸附到同一个半音;碰撞就把后一个再升半音——
1090
+ 仍然由文本派生,只是保证同段里不出现两个一模一样的高音(听着像卡带)。
1091
+ """
1092
+ seen = {} # type -> {"n": 已出现次数, "pitch": 已用音高集}
1093
+ for c in seg:
1094
+ st = seen.setdefault(c["type"], {"n": 0, "pitch": set()})
1095
+ if st["n"]:
1096
+ c["gain"] = _cue_gain_decay(c.get("gain", 0.0), st["n"])
1097
+ st["n"] += 1
1098
+ p = c.get("pitch")
1099
+ if p is not None:
1100
+ p = float(p)
1101
+ guard = 0
1102
+ while round(p, 2) in st["pitch"] and guard < 12:
1103
+ p *= 2.0 ** (1.0 / 12.0)
1104
+ guard += 1
1105
+ c["pitch"] = round(p, 2)
1106
+ st["pitch"].add(round(p, 2))
1107
+ return seg
1108
+
1109
+
1059
1110
  def cues(tl, script):
1060
1111
  """画面提示点钩子:render.py --stage cues 会调它(没有这个函数就不调)。
1061
1112
 
@@ -1065,7 +1116,16 @@ def cues(tl, script):
1065
1116
  v0.5:条目落位步长与场景绘制共用 plan["pacing"]["item_stagger"],
1066
1117
  两边读同一个数,否则音效会错位(这是 v0.4 写死 0.32 留下的坑)。
1067
1118
 
1068
- 返回 [{"at": 时刻, "type": 配方名, ...参数}];
1119
+ v0.7(references/audio-variation.md §3.4):提示点改为按**场景 kind 与内容**生成,
1120
+ 不再是"每段 ui_blip 三连 + 结尾 answer_chime"的同质化固定套路(跨片音效雷同的
1121
+ 来源之一):
1122
+ hook : 段首 impact(size 按字数 0.9~1.3)+ 落定 riser
1123
+ body : 条目落位 ui_blip(pitch 由条目文本 sha1 派生)+ items>=3 补 answer_chime
1124
+ + 段尾 whoosh(转场到下一段)
1125
+ quote : reverse_swell + 落定轻 impact
1126
+ outro : answer_chime + 片尾 sub_drop
1127
+ 同段内同类型 cue 重复时 gain 逐次再降 2 dB(-2/-4/-6)、pitch 必须不同。
1128
+ 返回 schema 不变:[{"at": 时刻, "type": 配方名, ...参数}];
1069
1129
  at 可以写 "abs:12.5" / 12.5 / "l03+0.35"(相对该段 start)/"l03"
1070
1130
  type 见 references/sfx-design.md 的配方表,或 sfxkit.list_recipes()
1071
1131
  """
@@ -1073,13 +1133,58 @@ def cues(tl, script):
1073
1133
  stagger = 0.32
1074
1134
  if not plan.get("legacy"):
1075
1135
  stagger = float((plan.get("pacing") or {}).get("item_stagger") or 0.32)
1136
+ scenes = list(tl.get("scenes") or [])
1076
1137
  out = []
1077
- for s in tl["scenes"]:
1078
- if s.get("kind") == "body":
1079
- # 条目逐条落位:第一项在 0.48s 出现,之后每 stagger 秒一个(和 _draw_items 同一节拍)
1080
- for i, _it in enumerate(s.get("items") or []):
1081
- out.append({"at": "%s+%.2f" % (s["id"], 0.48 + stagger * i),
1082
- "type": "ui_blip", "pitch": 1760.0 + 110.0 * i, "gain": -6})
1083
- if s.get("kind") == "outro":
1084
- out.append({"at": "%s+0.35" % s["id"], "type": "answer_chime", "gain": -4})
1138
+ for i, s in enumerate(scenes):
1139
+ sid = str(s.get("id") or ("l%02d" % (i + 1)))
1140
+ kind = str(s.get("kind") or "body")
1141
+ end = float(s.get("end") or 0.0)
1142
+ dur = float(s.get("dur") or max(0.0, end - float(s.get("start") or 0.0)))
1143
+ has_next = (i + 1) < len(scenes)
1144
+ seg = []
1145
+
1146
+ def _at(off):
1147
+ """本段相对时刻 -> cue 的 at 写法(id+偏移,保持返回 schema 不变)。"""
1148
+ return "%s+%.2f" % (sid, max(0.0, float(off)))
1149
+
1150
+ if kind == "hook":
1151
+ # 开场重击按文案字数给"体量":字数越多 size 越大(0.9~1.3),避免每片同一记闷响
1152
+ text = str(s.get("text") or s.get("big") or "")
1153
+ n_char = len("".join(text.split()))
1154
+ size = round(0.9 + 0.4 * min(1.0, n_char / 40.0), 2)
1155
+ seg.append({"at": _at(0.10), "type": "impact", "size": size, "gain": -2})
1156
+ seg.append({"at": _at(min(1.2, max(0.35, dur * 0.28))),
1157
+ "type": "riser", "dur": 1.2, "gain": -6})
1158
+ elif kind == "quote":
1159
+ # 引语段:先来一层反向渐强铺底,落定时轻一点的一击
1160
+ seg.append({"at": _at(0.05), "type": "reverse_swell", "dur": 1.2, "gain": -5})
1161
+ seg.append({"at": _at(min(1.4, max(0.5, dur * 0.30))),
1162
+ "type": "impact", "size": 0.6, "gain": -8})
1163
+ elif kind == "outro":
1164
+ # 收尾:答对钟声 + 片尾 sub_drop(尾巴正好落在段尾,不留半截低频)
1165
+ seg.append({"at": _at(0.35), "type": "answer_chime", "gain": -4})
1166
+ seg.append({"at": _at(max(0.30, dur - 1.60)),
1167
+ "type": "sub_drop", "dur": 1.6, "gain": -5})
1168
+ else:
1169
+ # body(以及 emphasis/calm 等未单独设计的 kind:按正文处理,向后兼容)
1170
+ items = list(s.get("items") or [])
1171
+ for j, it in enumerate(items):
1172
+ # 条目逐条落位:第一项在 0.48s 出现,之后每 stagger 秒一个(与 _draw_items 同一拍)
1173
+ at = 0.48 + stagger * j
1174
+ if at > dur - 0.12: # 极短段:放不下的条目不再排提示点(避免响到下一段)
1175
+ break
1176
+ txt = it.get("text") if isinstance(it, dict) else it
1177
+ seg.append({"at": _at(at), "type": "ui_blip",
1178
+ "pitch": _item_pitch(txt), "gain": -6})
1179
+ blips = [c for c in seg if c["type"] == "ui_blip"]
1180
+ if len(items) >= 3 and blips and dur > 0.6:
1181
+ # 最后一个条目落定后的下一拍补一声"猜中了",给列表收尾
1182
+ last_at = float(blips[-1]["at"].split("+", 1)[1])
1183
+ t_chime = min(last_at + stagger, max(last_at + 0.20, dur - 0.30))
1184
+ seg.append({"at": _at(t_chime), "type": "answer_chime", "gain": -6})
1185
+ if has_next and dur > 0.6:
1186
+ # 段尾 whoosh:从本段扫进下一段(C 路会在段界附近给转场类音效让位)
1187
+ seg.append({"at": _at(max(0.0, dur - 0.45)),
1188
+ "type": "whoosh", "dur": 0.9, "gain": -6})
1189
+ out.extend(_dedupe_segment(seg))
1085
1190
  return out
@@ -1,8 +1,12 @@
1
- /* engine.js — Canvas2D 逐帧确定性动画内核(v0.3.0)
1
+ /* engine.js — Canvas2D 逐帧确定性动画内核(v0.8.0)
2
2
  *
3
3
  * 改编自 ai-history-film(MIT)video/web/js/engine.js 的「逐帧 seek + 场景 + cue」思路,
4
4
  * 但去掉 GSAP 与 three.js,改成原生 Canvas2D + 自写缓动,不 vendor 任何三方 JS。
5
5
  *
6
+ * v0.8.0 新增:风格注入(window.__STYLE__)。webengine.open_page(style=...) 在页面加载前用
7
+ * add_init_script 写入它,本文件据此设置画布 w/h、帧率、字体栈与底色;**没有 __STYLE__ 时
8
+ * 所有取值退回 v0.7 常量,像素与旧版逐字节一致**(style=None 的兼容性硬要求)。
9
+ *
6
10
  * 核心约定:
7
11
  * - 每帧从零重画:画面只由「帧号 f」决定,同一帧号永远画同一张图(逐帧渲染可复现)。
8
12
  * - scene(id, start, dur, build):场景 = 时间窗口 + 若干逐帧绘制回调,窗口外不执行。
@@ -13,12 +17,69 @@
13
17
  (function (global) {
14
18
  'use strict';
15
19
 
16
- var W = 1920, H = 1080, FPS = 30, BG = '#0b0e14';
20
+ /* ---------------- v0.8.0:风格包(window.__STYLE__) ----------------
21
+ * 由 webengine 注入(页面自己直接打开时没有)。不注入 = 全部退回下面这行常量。
22
+ * 只读 payload,不在页面里做任何派生计算;同 payload 永远渲染出同一张图。 */
23
+ var ST = (global.__STYLE__ && typeof global.__STYLE__ === 'object') ? global.__STYLE__ : null;
24
+ var ST_THEME = (ST && ST.theme && typeof ST.theme === 'object') ? ST.theme : null;
25
+ var ST_CANVAS = (ST && ST.canvas && typeof ST.canvas === 'object') ? ST.canvas : null;
26
+
27
+ /* 页面在 engine.js 之前自己声明过的帧率(模板不声明 → null)。页面契约说
28
+ * "window.FPS = 页面时间轴帧率,渲染器据此算 -r / -g",所以声明过就不能被默认 30 顶掉
29
+ * —— 否则 webengine 的"页面 FPS 与注入 fps 不一致就报错"永远看不到真实页面帧率。 */
30
+ var DECLARED_FPS = _num(global.FPS, null);
31
+ var W = 1920, H = 1080, BG = '#0b0e14';
32
+ var FPS = (DECLARED_FPS != null && DECLARED_FPS > 0) ? Math.round(DECLARED_FPS) : 30;
33
+ var DEFAULT_DURATION = 12.0; // 页面没给时长时的兜底;film.js 用它区分"显式时长"与"兜底值"
17
34
  var FAMILY = '"Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif';
18
35
  var TAU = Math.PI * 2;
19
36
 
37
+ function _num(v, fb) { var n = Number(v); return (v != null && isFinite(n)) ? n : fb; }
38
+ function _txt(v, fb) { return (typeof v === 'string' && v.length > 0) ? v : fb; }
39
+
20
40
  var canvas = document.getElementById('stage');
21
41
  if (!canvas) throw new Error('engine.js: 页面缺少 <canvas id="stage">');
42
+
43
+ /* 画布尺寸:只在注入 style 时改(webengine 侧已校验 16:9 横屏偶数,这里再兜一层)。
44
+ * 没注入时 canvas 属性 / 内联样式一个字都不动 —— 旧行为不变。 */
45
+ if (ST_CANVAS) {
46
+ var _w = Math.round(_num(ST_CANVAS.w, W)), _h = Math.round(_num(ST_CANVAS.h, H));
47
+ if (_w > 0 && _h > 0 && Math.abs(_w / _h - 16 / 9) > 1e-3) {
48
+ throw new Error('engine.js: __STYLE__.canvas 必须是 16:9 横屏,收到 ' + _w + 'x' + _h);
49
+ }
50
+ if (_w > 0 && _h > 0) {
51
+ W = _w; H = _h;
52
+ canvas.width = W;
53
+ canvas.height = H;
54
+ canvas.style.width = W + 'px';
55
+ canvas.style.height = H + 'px';
56
+ document.documentElement.style.width = W + 'px';
57
+ document.documentElement.style.height = H + 'px';
58
+ if (document.body) {
59
+ document.body.style.width = W + 'px';
60
+ document.body.style.height = H + 'px';
61
+ }
62
+ }
63
+ /* 帧率:只有调用方显式给了 fps 才改。帧率是 timeline 契约,不是画风 ——
64
+ * 不能因为注入了 style 就悄悄把页面帧率换掉(那样分块帧号会整体错位)。
65
+ * 页面自己声明过 window.FPS 且与注入值不一致时**不覆盖**:保留页面值,让
66
+ * webengine.open_page 的契约检查报出"页面 window.FPS=X 与注入的 fps=Y 不一致"。
67
+ * 以前这里无条件覆盖 → 页面 window.FPS 被改成注入值,那条检查永远相等,
68
+ * 文档承诺的"fps 不一致就报错"对 engine.js 页面从未真正触发过。 */
69
+ var _fps = _num(ST_CANVAS.fps, null);
70
+ var _declared = (DECLARED_FPS != null && DECLARED_FPS > 0) ? Math.round(DECLARED_FPS) : null;
71
+ if (_fps != null && _fps > 0) {
72
+ if (_declared != null && _declared !== Math.round(_fps)) {
73
+ // 保留页面声明的帧率(FPS 已在上面按 DECLARED_FPS 初始化),交给 webengine 报错
74
+ } else {
75
+ FPS = Math.round(_fps);
76
+ }
77
+ }
78
+ }
79
+
80
+ if (ST_THEME) BG = _txt(ST_THEME.bg, BG);
81
+ if (ST && ST.typography) FAMILY = _txt(ST.typography.font_stack, FAMILY);
82
+
22
83
  var ctx = canvas.getContext('2d', { alpha: false, desynchronized: false });
23
84
 
24
85
  /* ---------------------------------------------------------------- 数学 / 缓动 */
@@ -409,6 +470,20 @@
409
470
  function getCues() { return copySorted(cueList); }
410
471
  function getEvents() { return copySorted(eventList); }
411
472
 
473
+ /* 场景表推出的时间轴总长(最后一个场景的 end)。
474
+ * 为什么要它:页面契约里的 DURATION 必须等于真正的成片秒数,而 v0.3 的模板在 film.js
475
+ * 开头写死 global.DURATION = 12.0 —— 加一幕/改一幕时长后忘了同步这个常量,就会
476
+ * "明明改了场景、成片还是 12 秒"或者直接和 timeline.json 对不上(render.py 报
477
+ * 页面 DURATION 与 timeline 总时长不一致)。让 DURATION 从场景表推,就不会漏改。 */
478
+ function timelineEnd() {
479
+ var end = 0;
480
+ for (var i = 0; i < scenes.length; i++) {
481
+ var e = Number(scenes[i].start) + Number(scenes[i].dur);
482
+ if (isFinite(e) && e > end) end = e;
483
+ }
484
+ return Math.round(end * 1000) / 1000;
485
+ }
486
+
412
487
  /* ---------------------------------------------------------------- 逐帧渲染 */
413
488
  var currentFrame = -1;
414
489
 
@@ -467,11 +542,14 @@
467
542
  /* ---------------------------------------------------------------- 导出 */
468
543
  var FK = {
469
544
  W: W, H: H, FPS: FPS, BG: BG, TAU: TAU, FAMILY: FAMILY,
545
+ style: ST, // 注入的 __STYLE__(没有就是 null);qa / selftest 可据此断言
546
+ defaultDuration: DEFAULT_DURATION,
470
547
  canvas: canvas, ctx: ctx,
471
548
  clamp: clamp, lerp: lerp, seg: seg, E: E, hash: hash, rng: rng,
472
549
  g: g,
473
550
  scene: scene, always: always, watch: watch, qaReset: qaReset,
474
551
  texts: texts, watched: watched, drawnKeys: drawnKeyList,
552
+ timelineEnd: timelineEnd,
475
553
  getCues: getCues, getEvents: getEvents, renderFrame: renderFrame, boot: boot,
476
554
  frame: function () { return currentFrame; }
477
555
  };
@@ -480,6 +558,6 @@
480
558
  global.renderFrame = renderFrame;
481
559
  global.getCues = getCues;
482
560
  global.getEvents = getEvents;
483
- if (typeof global.DURATION === 'undefined') global.DURATION = 12.0;
561
+ if (typeof global.DURATION === 'undefined') global.DURATION = DEFAULT_DURATION;
484
562
  global.ready = false;
485
563
  })(window);