@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.
- package/CHANGELOG.md +951 -0
- package/README.md +89 -21
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +23 -7
- package/package.json +7 -3
- package/schemas/film.schema.json +235 -4
- package/skills/video-studio/SKILL.md +299 -27
- package/skills/video-studio/assets/film.json +7 -14
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +115 -10
- package/skills/video-studio/assets/web/engine.js +81 -3
- package/skills/video-studio/assets/web/film.js +249 -101
- package/skills/video-studio/assets/web/index.html +9 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +159 -0
- package/skills/video-studio/references/audio.md +67 -5
- package/skills/video-studio/references/change-impact.md +2 -2
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +16 -1
- package/skills/video-studio/scripts/filmcheck.py +202 -12
- package/skills/video-studio/scripts/filmkit.py +792 -56
- package/skills/video-studio/scripts/gates.py +161 -14
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +154 -23
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +1134 -78
- package/skills/video-studio/scripts/scorekit.py +305 -76
- package/skills/video-studio/scripts/selfcheck.py +576 -2
- package/skills/video-studio/scripts/sfxkit.py +262 -44
- package/skills/video-studio/scripts/stagewatch.py +159 -36
- package/skills/video-studio/scripts/stylekit.py +137 -18
- package/skills/video-studio/scripts/voicekit.py +23 -7
- package/skills/video-studio/scripts/webengine.py +1186 -85
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +310 -59
- package/tools/estimate.py +251 -61
- package/tools/golden_frames.py +664 -0
- package/tools/selftest_noscipy.py +70 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""audio_similarity.py — 配乐/音效同质化诊断 + 音频改造验收(只读,不产片)。
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python tools/audio_similarity.py # 打印 4 组对照
|
|
7
|
+
python tools/audio_similarity.py --strict # 额外按契约阈值判定,失败 exit 1
|
|
8
|
+
python tools/audio_similarity.py --out dir/ # 把对照样本写成 wav 便于人耳试听
|
|
9
|
+
|
|
10
|
+
四组对照(阈值来自 skills/video-studio/references/audio-variation.md §5):
|
|
11
|
+
|
|
12
|
+
A 跨片配乐(v0.7):同风格/同 bpm/同调、只换内容指纹 -> 相关性(目标 <= 0.85);
|
|
13
|
+
换风格 -> 相关性(目标 <= 0.52,且不能比改造前更接近)
|
|
14
|
+
B 跨片音效(v0.7):同 cue 序列,legacy(不传 style/content_fp)必须逐样本相同;
|
|
15
|
+
不同 content_fp 必须不同(否则"跨片音效都一样"没被修掉)
|
|
16
|
+
C 片内分离(v0.7):duck 默认深度(目标 >= 3.5 dB)与配乐/音效的频谱质心对照
|
|
17
|
+
D 旁白优先(v0.7.1):人声段 旁白/背景 响度差(目标 >= 12 dB)+ 侧链让位 >= 4 dB
|
|
18
|
+
|
|
19
|
+
本脚本在改造前是"诊断工具",改造后是"验收工具":--strict 会逐条对阈值判定。
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
HERE = Path(__file__).resolve().parent
|
|
31
|
+
SCRIPTS = HERE.parent / "skills" / "video-studio" / "scripts"
|
|
32
|
+
sys.path.insert(0, str(SCRIPTS))
|
|
33
|
+
|
|
34
|
+
# 契约 §5 的阈值(改契约时两边一起改)
|
|
35
|
+
THRESH_A_SAME_STYLE_MAX = 0.85 # 同风格、只换内容指纹的相关性上限
|
|
36
|
+
THRESH_A_CROSS_STYLE_MAX = 0.52 # 换风格的相关性上限(改造前 0.534)
|
|
37
|
+
# 实测余量提示(v0.7.2):本机稳定测到 0.514(连跑 3 次逐位相同),离阈值只剩 0.006。
|
|
38
|
+
# 这个生成链是确定性的,所以换平台/换 numpy 若把 0.514 推到 0.52 以上,
|
|
39
|
+
# 更可能是「风格差异化真的缩水了」而不是浮点噪声 —— 请先查 scorekit 的变奏参数,
|
|
40
|
+
# 别第一反应去抬阈值(抬到 0.53 就离改造前 0.534 只剩 0.004,等于这条门禁白设)。
|
|
41
|
+
THRESH_C_DUCK_MIN = 3.5 # duck 默认深度下限(dB)
|
|
42
|
+
THRESH_D_SNR_MIN = 12.0 # 人声段 旁白/背景 响度差下限(dB)
|
|
43
|
+
|
|
44
|
+
FAILS = []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ok(name, cond, detail):
|
|
48
|
+
print(" [%s] %s %s" % ("PASS" if cond else "FAIL", name, detail))
|
|
49
|
+
if not cond:
|
|
50
|
+
FAILS.append("%s: %s" % (name, detail))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fmt_db(v):
|
|
54
|
+
"""dB 值格式化:NaN(无可判定区间)打印成 'n/a' 而不是让 %f 抛 TypeError。"""
|
|
55
|
+
try:
|
|
56
|
+
v = float(v)
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
return "n/a"
|
|
59
|
+
return "n/a" if v != v else ("%.1f" % v)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _rms(x):
|
|
63
|
+
a = np.asarray(x, dtype=np.float64)
|
|
64
|
+
return float(np.sqrt(np.mean(np.square(a)))) if a.size else 0.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _corr(x, y):
|
|
68
|
+
x = np.asarray(x, dtype=np.float64)
|
|
69
|
+
y = np.asarray(y, dtype=np.float64)
|
|
70
|
+
if x.ndim == 2:
|
|
71
|
+
x = x.mean(axis=1)
|
|
72
|
+
if y.ndim == 2:
|
|
73
|
+
y = y.mean(axis=1)
|
|
74
|
+
n = min(len(x), len(y))
|
|
75
|
+
x, y = x[:n], y[:n]
|
|
76
|
+
if n == 0:
|
|
77
|
+
return float("nan")
|
|
78
|
+
sx, sy = float(np.std(x)), float(np.std(y))
|
|
79
|
+
if sx <= 1e-12 or sy <= 1e-12:
|
|
80
|
+
# 常量/全静音信号:相关系数没有定义。旧实现返回 0,于是"配乐渲染成静音"这种
|
|
81
|
+
# 最严重的回归会让 A1/A2 反而判 PASS(0 <= 0.85)。
|
|
82
|
+
return float("nan")
|
|
83
|
+
return float(np.mean((x - x.mean()) * (y - y.mean())) / (sx * sy))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _centroid_hz(x, sr=48000):
|
|
87
|
+
x = np.asarray(x, dtype=np.float64)
|
|
88
|
+
if x.ndim == 2:
|
|
89
|
+
x = x.mean(axis=1)
|
|
90
|
+
spec = np.abs(np.fft.rfft(x))
|
|
91
|
+
freqs = np.fft.rfftfreq(len(x), 1.0 / sr)
|
|
92
|
+
return float((spec * freqs).sum() / (spec.sum() + 1e-12))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _utf8_stdio():
|
|
96
|
+
for st in (sys.stdout, sys.stderr):
|
|
97
|
+
try:
|
|
98
|
+
if st is not None and not st.isatty():
|
|
99
|
+
st.reconfigure(encoding="utf-8", errors="replace")
|
|
100
|
+
except Exception: # noqa: BLE001
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _music(dur, style, fp):
|
|
105
|
+
import scorekit as SK
|
|
106
|
+
samples, _rep = SK.render_styled(dur, style=style, bpm=110, key="Dm",
|
|
107
|
+
mood="steady", seed=7, fingerprint=fp)
|
|
108
|
+
return samples
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cue_track(content_fp=None, style=None, key=None):
|
|
112
|
+
import filmkit as K
|
|
113
|
+
scenes = [{"id": "l01", "kind": "hook", "start": 0.0, "end": 2.0},
|
|
114
|
+
{"id": "l02", "kind": "body", "start": 2.0, "end": 6.0}]
|
|
115
|
+
rows = [{"at": "l02", "type": "ui_click"}, {"at": "l02+0.5", "type": "whoosh"}]
|
|
116
|
+
bus = K.CueBus(scenes=scenes, total=6.0)
|
|
117
|
+
bus.extend(rows)
|
|
118
|
+
return bus.build_audio(total=6.0, verbose=False, style=style,
|
|
119
|
+
content_fp=content_fp, key=key)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _voice_priority_snr(sr=48000, guard_snr_db=12.0):
|
|
123
|
+
"""量"人声比背景响多少":用真实 scorekit 配乐 + 类人声,按 st_audio 的默认参数混音。
|
|
124
|
+
|
|
125
|
+
返回 (人声活跃区 SNR dB, 间隙背景 RMS, 人声处背景额外让位 dB)。
|
|
126
|
+
口径与 `filmkit.voice_snr` 一致:只统计人声活跃区(含间隙会稀释问题)。
|
|
127
|
+
|
|
128
|
+
guard_snr_db 默认 12(= st_audio 默认,量"最终交付"的 SNR);测"侧链本身让位多少"
|
|
129
|
+
时传 0 关掉兜底——否则兜底的整体补压会被算成侧链效果,D2 在侧链被删掉时也能过。
|
|
130
|
+
"""
|
|
131
|
+
import filmkit as K
|
|
132
|
+
import numpy as np
|
|
133
|
+
import scorekit as SK
|
|
134
|
+
n = int(6.0 * sr)
|
|
135
|
+
t = np.arange(n) / float(sr)
|
|
136
|
+
voice = np.zeros(n, dtype=np.float64)
|
|
137
|
+
for a, b in ((0.2, 2.6), (3.4, 5.8)): # 两段"说话",中间留间隙
|
|
138
|
+
i, j = int(a * sr), int(b * sr)
|
|
139
|
+
voice[i:j] = 0.12 * np.sin(2 * np.pi * 220 * t[i:j])
|
|
140
|
+
# 真实配乐(scorekit 输出 RMS 约 0.15,比"类人声"响 —— 正是需要侧链/兜底的场景)
|
|
141
|
+
music, _ = SK.render_styled(6.0, style="tech-deep", bpm=110, key="Am",
|
|
142
|
+
mood="steady", seed=7, fingerprint="d-group")
|
|
143
|
+
music = np.asarray(music, dtype=np.float32)
|
|
144
|
+
out = K.mix_film(voice.astype(np.float32), music, None, total=6.0, fade=0.0,
|
|
145
|
+
music_gain_db=-12.0, voice_duck_db=6.0,
|
|
146
|
+
guard_snr_db=float(guard_snr_db), verbose=False)
|
|
147
|
+
v2 = np.stack([voice, voice], axis=1)
|
|
148
|
+
bg = out - v2
|
|
149
|
+
snr = K.voice_snr(voice.astype(np.float32), bg)
|
|
150
|
+
gap = slice(int(2.8 * sr), int(3.2 * sr))
|
|
151
|
+
gap_rms = float(np.sqrt(np.mean(bg[gap] ** 2)))
|
|
152
|
+
# 人声处背景相对"未压背景"的额外让位:未压背景 = music * 10^(-12/20)
|
|
153
|
+
base_bg = music * 10 ** (-12.0 / 20.0)
|
|
154
|
+
b_talk = slice(int(0.5 * sr), int(2.2 * sr))
|
|
155
|
+
duck_eff = 20 * np.log10((float(np.sqrt(np.mean(base_bg[b_talk] ** 2))) + 1e-12) /
|
|
156
|
+
(float(np.sqrt(np.mean(bg[b_talk] ** 2))) + 1e-12))
|
|
157
|
+
return snr, gap_rms, duck_eff
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _duck_default():
|
|
161
|
+
src = (SCRIPTS / "render.py").read_text(encoding="utf-8")
|
|
162
|
+
m = re.search(r"duck_default\s*=\s*([0-9.]+)", src)
|
|
163
|
+
return float(m.group(1)) if m else float("nan")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv=None):
|
|
167
|
+
_utf8_stdio()
|
|
168
|
+
ap = argparse.ArgumentParser(description="配乐/音效相似度诊断(v0.7 验收)")
|
|
169
|
+
ap.add_argument("--out", default="", help="把对照样本写成 wav 到这个目录")
|
|
170
|
+
ap.add_argument("--dur", type=float, default=20.0, help="配乐对照时长(秒)")
|
|
171
|
+
ap.add_argument("--strict", action="store_true", help="按契约 §5 阈值判定,失败 exit 1")
|
|
172
|
+
args = ap.parse_args(argv)
|
|
173
|
+
|
|
174
|
+
import sfxkit as SX
|
|
175
|
+
|
|
176
|
+
print("=" * 72)
|
|
177
|
+
print("A. 跨片配乐(差异来源 = 风格 / bpm / 调 / 内容指纹 / seed)")
|
|
178
|
+
print("=" * 72)
|
|
179
|
+
a1 = _music(args.dur, "tech-deep", "content-a")
|
|
180
|
+
a2 = _music(args.dur, "tech-deep", "content-b")
|
|
181
|
+
a3 = _music(args.dur, "magazine", "content-a")
|
|
182
|
+
a4 = _music(args.dur, "tech-deep", "content-a") # 同指纹复渲染,验证确定性
|
|
183
|
+
same_style = _corr(a1, a2)
|
|
184
|
+
cross_style = _corr(a1, a3)
|
|
185
|
+
rms_a1 = _rms(a1)
|
|
186
|
+
print(" 配乐非静音(RMS > -60 dBFS) : %s (rms=%.4f,静音时相关系数无定义)"
|
|
187
|
+
% (rms_a1 > 0.001, rms_a1))
|
|
188
|
+
print(" 同风格·同bpm·同调,只换内容指纹 : corr = %.3f (目标 <= %.2f)"
|
|
189
|
+
% (same_style, THRESH_A_SAME_STYLE_MAX))
|
|
190
|
+
print(" 换风格(tech-deep -> magazine) : corr = %.3f (目标 <= %.2f)"
|
|
191
|
+
% (cross_style, THRESH_A_CROSS_STYLE_MAX))
|
|
192
|
+
print(" 同指纹两次渲染逐样本相同 : %s" % bool(np.array_equal(a1, a4)))
|
|
193
|
+
if args.strict:
|
|
194
|
+
# A0 是前置条件:若配乐渲染成静音/常量,A1/A2 的相关系数没有定义(NaN),
|
|
195
|
+
# 必须显式判 FAIL,而不是让 NaN 比较悄悄变成 False 后只剩一句 corr=nan。
|
|
196
|
+
_ok("A0 配乐非静音", rms_a1 > 0.001, "rms=%.4f" % rms_a1)
|
|
197
|
+
_ok("A1 同风格换指纹可区分", same_style <= THRESH_A_SAME_STYLE_MAX,
|
|
198
|
+
"corr=%.3f" % same_style)
|
|
199
|
+
_ok("A2 换风格更不像", cross_style <= THRESH_A_CROSS_STYLE_MAX,
|
|
200
|
+
"corr=%.3f" % cross_style)
|
|
201
|
+
_ok("A3 配乐可复现", bool(np.array_equal(a1, a4)), "同指纹逐样本一致")
|
|
202
|
+
|
|
203
|
+
print()
|
|
204
|
+
print("=" * 72)
|
|
205
|
+
print("B. 跨片音效(CueBus 内容派生 seed)")
|
|
206
|
+
print("=" * 72)
|
|
207
|
+
b_legacy1, b_legacy2 = _cue_track(), _cue_track()
|
|
208
|
+
b_new_a = _cue_track("aaaa1111", "tech-deep", "Dm")
|
|
209
|
+
b_new_b = _cue_track("bbbb2222", "tech-deep", "Dm")
|
|
210
|
+
b_new_a2 = _cue_track("aaaa1111", "tech-deep", "Dm")
|
|
211
|
+
legacy_same = bool(np.array_equal(b_legacy1, b_legacy2))
|
|
212
|
+
cross_diff = not bool(np.array_equal(b_new_a, b_new_b))
|
|
213
|
+
repeat_same = bool(np.array_equal(b_new_a, b_new_a2))
|
|
214
|
+
print(" legacy(不传 style/fp)两次相同 : %s (必须 True,v0.4 兼容)" % legacy_same)
|
|
215
|
+
print(" 不同内容指纹 -> 音效轨不同 : %s (必须 True)" % cross_diff)
|
|
216
|
+
print(" 同内容指纹两次相同 : %s (必须 True,可复现)" % repeat_same)
|
|
217
|
+
if args.strict:
|
|
218
|
+
_ok("B1 legacy 逐样本兼容", legacy_same, "两次渲染相同")
|
|
219
|
+
_ok("B2 跨片音效可区分", cross_diff, "不同 content_fp 不再逐样本相同")
|
|
220
|
+
_ok("B3 音效可复现", repeat_same, "同 content_fp 逐样本一致")
|
|
221
|
+
|
|
222
|
+
print()
|
|
223
|
+
print("=" * 72)
|
|
224
|
+
print("C. 片内分离:混音 duck 深度 + 频谱质心对照")
|
|
225
|
+
print("=" * 72)
|
|
226
|
+
duck = _duck_default()
|
|
227
|
+
print(" duck 默认深度 : %.1f dB (目标 >= %.1f)"
|
|
228
|
+
% (duck, THRESH_C_DUCK_MIN))
|
|
229
|
+
for name, x in (("impact", SX.render("impact", seed=1000)),
|
|
230
|
+
("whoosh", SX.render("whoosh", seed=1000)),
|
|
231
|
+
("riser", SX.render("riser", seed=1000)),
|
|
232
|
+
("music(前4s)", a1[: 48000 * 4])):
|
|
233
|
+
print(" %-12s 频谱质心 ~ %6.0f Hz" % (name, _centroid_hz(x)))
|
|
234
|
+
print(" 提示音调性跟随:in_key 现在接受 key=(配乐调性),不再硬编码 Dm。")
|
|
235
|
+
if args.strict:
|
|
236
|
+
_ok("C1 duck 加深", duck >= THRESH_C_DUCK_MIN, "duck=%.1f dB" % duck)
|
|
237
|
+
|
|
238
|
+
print()
|
|
239
|
+
print("=" * 72)
|
|
240
|
+
print("D. 旁白优先:人声与背景的响度差(v0.7.1)")
|
|
241
|
+
print("=" * 72)
|
|
242
|
+
snr_db, base_db, _duck_with_guard = _voice_priority_snr()
|
|
243
|
+
# 侧链让位单独量一次(guard_snr_db=0):兜底的整体补压也是"背景变低",
|
|
244
|
+
# 混在一起算会把 D2 变成"guard 生效"的证据,侧链被删掉时 D2 照样 PASS。
|
|
245
|
+
_snr_no_guard, _gap_no_guard, duck_db_eff = _voice_priority_snr(guard_snr_db=0.0)
|
|
246
|
+
print(" 人声段 旁白/背景 响度差 : %s dB (目标 >= %.1f,含 guard 兜底)"
|
|
247
|
+
% (_fmt_db(snr_db), THRESH_D_SNR_MIN))
|
|
248
|
+
print(" 人声处背景额外让位 : %s dB (目标 >= 4,仅侧链,guard 关)"
|
|
249
|
+
% _fmt_db(duck_db_eff))
|
|
250
|
+
print(" 间隙里背景电平 : %.3f(应为恢复后的基线)" % base_db)
|
|
251
|
+
if args.strict:
|
|
252
|
+
_ok("D1 旁白听得清", snr_db is not None and snr_db == snr_db
|
|
253
|
+
and snr_db >= THRESH_D_SNR_MIN, "SNR=%s dB" % _fmt_db(snr_db))
|
|
254
|
+
_ok("D2 旁白侧链生效", duck_db_eff >= 4.0, "让位 %s dB" % _fmt_db(duck_db_eff))
|
|
255
|
+
|
|
256
|
+
if args.out:
|
|
257
|
+
out = Path(args.out)
|
|
258
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
from scorekit import save as music_save
|
|
260
|
+
music_save(out / "music_tech_deep_a.wav", a1)
|
|
261
|
+
music_save(out / "music_tech_deep_b.wav", a2)
|
|
262
|
+
music_save(out / "music_magazine.wav", a3)
|
|
263
|
+
for t in ("whoosh", "riser", "impact"):
|
|
264
|
+
SX.save(out / ("sfx_%s.wav" % t), SX.render(t, seed=1000))
|
|
265
|
+
print("\n对照样本已写入: %s" % out)
|
|
266
|
+
|
|
267
|
+
if args.strict:
|
|
268
|
+
print()
|
|
269
|
+
if FAILS:
|
|
270
|
+
print("STRICT FAIL: %d 项未达标" % len(FAILS))
|
|
271
|
+
for f in FAILS:
|
|
272
|
+
print(" - %s" % f)
|
|
273
|
+
return 1
|
|
274
|
+
print("STRICT PASS: 全部阈值达标(A/B/C/D 四组)")
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__":
|
|
279
|
+
raise SystemExit(main())
|