@dsh-gis/dsh-video-studio-skill 0.9.1 → 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.
- package/CHANGELOG.md +473 -0
- package/README.md +263 -327
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/package.json +9 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +31 -42
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1216 -1123
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +2 -4
- package/skills/video-studio/assets/golden_frames.json +13 -10
- package/skills/video-studio/assets/scenes.py +153 -65
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/appearance.md +123 -0
- package/skills/video-studio/references/audio-variation.md +74 -93
- package/skills/video-studio/references/audio.md +64 -66
- package/skills/video-studio/references/change-impact.md +5 -5
- package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +16 -18
- package/skills/video-studio/references/frame-qa.md +48 -16
- package/skills/video-studio/references/pipeline.md +4 -4
- package/skills/video-studio/references/pitfalls.md +22 -3
- package/skills/video-studio/references/proposal.md +160 -0
- package/skills/video-studio/references/quality-gates.md +11 -11
- package/skills/video-studio/references/scene-authoring.md +2 -2
- package/skills/video-studio/references/scene-grammar.md +40 -42
- package/skills/video-studio/references/sfx-design.md +8 -6
- package/skills/video-studio/references/web-engine.md +8 -8
- package/skills/video-studio/references/word-timeline.md +9 -9
- package/skills/video-studio/requirements.txt +9 -13
- package/skills/video-studio/scripts/designkit.py +2115 -0
- package/skills/video-studio/scripts/filmcheck.py +22 -48
- package/skills/video-studio/scripts/filmkit.py +2980 -2904
- package/skills/video-studio/scripts/gates.py +4 -4
- package/skills/video-studio/scripts/grammar.py +73 -13
- package/skills/video-studio/scripts/musicgen.py +2042 -0
- package/skills/video-studio/scripts/preflight.py +14 -7
- package/skills/video-studio/scripts/previewkit.py +848 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +73 -39
- package/skills/video-studio/scripts/qaasr.py +26 -7
- package/skills/video-studio/scripts/qavmaf.py +89 -23
- package/skills/video-studio/scripts/render.py +545 -231
- package/skills/video-studio/scripts/scorekit.py +55 -1755
- package/skills/video-studio/scripts/selfcheck.py +815 -215
- package/skills/video-studio/scripts/sfxkit.py +27 -26
- package/skills/video-studio/scripts/stagewatch.py +24 -8
- package/skills/video-studio/scripts/stylekit.py +207 -888
- package/skills/video-studio/scripts/voicekit.py +508 -387
- package/skills/video-studio/scripts/webengine.py +71 -72
- package/tools/audio_similarity.py +64 -37
- package/tools/ci_check.py +20 -14
- package/tools/estimate.py +34 -29
- package/tools/golden_frames.py +72 -28
- package/tools/silhouette_qa.py +520 -168
- package/docs/demo-pil-sheet.png +0 -0
- package/docs/styles/demo-results.json +0 -76
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-before-after.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/skills/video-studio/assets/score_demo.json +0 -307
- package/skills/video-studio/references/style-packs.md +0 -283
|
@@ -9,18 +9,20 @@
|
|
|
9
9
|
|
|
10
10
|
四组对照(阈值来自 skills/video-studio/references/audio-variation.md §5):
|
|
11
11
|
|
|
12
|
-
A 跨片配乐(
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
A 跨片配乐(musicgen 参数化编排):
|
|
13
|
+
同参数/同 bpm/同调、只换内容指纹 -> 相关性(目标 <= 0.85);
|
|
14
|
+
换编排参数(mood + 音色) -> 相关性(目标 <= 0.52)
|
|
15
|
+
B 跨片音效:同 cue 序列,legacy(不传 style/content_fp)必须逐样本相同;
|
|
15
16
|
不同 content_fp 必须不同(否则"跨片音效都一样"没被修掉)
|
|
16
|
-
C
|
|
17
|
-
D
|
|
17
|
+
C 片内分离:duck 默认深度(目标 >= 3.5 dB)与配乐/音效的频谱质心对照
|
|
18
|
+
D 旁白优先:人声段 旁白/背景 响度差(目标 >= 12 dB)+ 侧链让位 >= 4 dB
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
两种用法:不带 --strict 是诊断(打印对照数值),--strict 是验收(逐条对阈值判定,失败 exit 1)。
|
|
20
21
|
"""
|
|
21
22
|
from __future__ import annotations
|
|
22
23
|
|
|
23
24
|
import argparse
|
|
25
|
+
import hashlib
|
|
24
26
|
import re
|
|
25
27
|
import sys
|
|
26
28
|
from pathlib import Path
|
|
@@ -32,12 +34,12 @@ SCRIPTS = HERE.parent / "skills" / "video-studio" / "scripts"
|
|
|
32
34
|
sys.path.insert(0, str(SCRIPTS))
|
|
33
35
|
|
|
34
36
|
# 契约 §5 的阈值(改契约时两边一起改)
|
|
35
|
-
THRESH_A_SAME_STYLE_MAX = 0.85 #
|
|
36
|
-
THRESH_A_CROSS_STYLE_MAX = 0.52 #
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
37
|
+
THRESH_A_SAME_STYLE_MAX = 0.85 # 同参数、只换内容指纹的相关性上限
|
|
38
|
+
THRESH_A_CROSS_STYLE_MAX = 0.52 # 换编排参数的相关性上限(改造前实测 0.534,抬到 0.53 门禁就没意义了)
|
|
39
|
+
# 配乐由 musicgen 参数化编排,这两条阈值是「跨片不能太像」的验收线:
|
|
40
|
+
# 同参数换指纹要 <=0.85,换编排参数要 <=0.52。
|
|
41
|
+
# 生成链是确定性的,换平台/换 numpy 若把实测值推过线,先查 musicgen 的参数有没有
|
|
42
|
+
# 变成死旋钮,别第一反应去抬阈值。
|
|
41
43
|
THRESH_C_DUCK_MIN = 3.5 # duck 默认深度下限(dB)
|
|
42
44
|
THRESH_D_SNR_MIN = 12.0 # 人声段 旁白/背景 响度差下限(dB)
|
|
43
45
|
|
|
@@ -101,10 +103,35 @@ def _utf8_stdio():
|
|
|
101
103
|
pass
|
|
102
104
|
|
|
103
105
|
|
|
104
|
-
def _music(dur,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
def _music(dur, profile, fp):
|
|
107
|
+
"""用参数化编排器现场生成一条配乐。
|
|
108
|
+
|
|
109
|
+
profile 是「一套编排参数」,对应旧版的一个 style id —— 换成 mood + 音色 + 和弦进行:
|
|
110
|
+
* "steady" -> 稳重:pad=saw / bass=sine / arp=pluck / lead=bell
|
|
111
|
+
* "magazine" -> 换一套色调:triangle 铺底 + bell 琶音 + hopeful
|
|
112
|
+
fp 除了派生 seed,还决定**和弦进行走哪一档**(结构层):旧版 scorekit 的指纹会换
|
|
113
|
+
进行档,这里用同一思路 —— 否则只换 seed 时两段配乐的相关性接近 1.0,A1 门禁
|
|
114
|
+
就成了「永远通过」的假门禁。
|
|
115
|
+
"""
|
|
116
|
+
import musicgen as MG
|
|
117
|
+
# 指纹 -> 两个**互相独立**的派生量:seed(音色/细节)与进行档(结构)。
|
|
118
|
+
# 早先用同一个 8 位片段同时取模,content-a / content-b 正好撞在一起(seed 与档位
|
|
119
|
+
# 全同 -> corr 恒 1.0,A1 门禁变成永远通过)。分开取片段才是真的"同一内容同一首、
|
|
120
|
+
# 换内容不像同一首"。
|
|
121
|
+
digest = hashlib.sha1(str(fp or "").encode("utf-8")).hexdigest()
|
|
122
|
+
seed = 7 + (int(digest[:12], 16) % (2 ** 31 - 1))
|
|
123
|
+
prog_idx = int(digest[12:20], 16) % 2
|
|
124
|
+
voices = {"steady": {"pad": "saw", "bass": "sine", "arp": "pluck", "lead": "bell"},
|
|
125
|
+
"magazine": {"pad": "triangle", "bass": "sine", "arp": "bell", "lead": "pluck"}}[profile]
|
|
126
|
+
progressions = [
|
|
127
|
+
{"calm": ["i", "VI", "iv", "v"], "body": ["i", "VI", "III", "VII"],
|
|
128
|
+
"drive": ["i", "VII", "VI", "V"], "climax": ["i", "VII", "VI", "V"]},
|
|
129
|
+
{"calm": ["i", "iv", "VI", "i"], "body": ["i", "III", "VII", "VI"],
|
|
130
|
+
"drive": ["i", "VI", "VII", "V"], "climax": ["i", "VI", "VII", "V"]},
|
|
131
|
+
][prog_idx]
|
|
132
|
+
samples, _rep = MG.render(float(dur), bpm=110.0, key="Dm", scale="minor",
|
|
133
|
+
mood="steady" if profile == "steady" else "hopeful",
|
|
134
|
+
seed=seed, voices=voices, progressions=progressions)
|
|
108
135
|
return samples
|
|
109
136
|
|
|
110
137
|
|
|
@@ -120,7 +147,7 @@ def _cue_track(content_fp=None, style=None, key=None):
|
|
|
120
147
|
|
|
121
148
|
|
|
122
149
|
def _voice_priority_snr(sr=48000, guard_snr_db=12.0):
|
|
123
|
-
"""量"人声比背景响多少":用真实
|
|
150
|
+
"""量"人声比背景响多少":用真实 musicgen 配乐 + 类人声,按 st_audio 的默认参数混音。
|
|
124
151
|
|
|
125
152
|
返回 (人声活跃区 SNR dB, 间隙背景 RMS, 人声处背景额外让位 dB)。
|
|
126
153
|
口径与 `filmkit.voice_snr` 一致:只统计人声活跃区(含间隙会稀释问题)。
|
|
@@ -130,16 +157,16 @@ def _voice_priority_snr(sr=48000, guard_snr_db=12.0):
|
|
|
130
157
|
"""
|
|
131
158
|
import filmkit as K
|
|
132
159
|
import numpy as np
|
|
133
|
-
import
|
|
160
|
+
import musicgen as MG
|
|
134
161
|
n = int(6.0 * sr)
|
|
135
162
|
t = np.arange(n) / float(sr)
|
|
136
163
|
voice = np.zeros(n, dtype=np.float64)
|
|
137
164
|
for a, b in ((0.2, 2.6), (3.4, 5.8)): # 两段"说话",中间留间隙
|
|
138
165
|
i, j = int(a * sr), int(b * sr)
|
|
139
166
|
voice[i:j] = 0.12 * np.sin(2 * np.pi * 220 * t[i:j])
|
|
140
|
-
# 真实配乐(
|
|
141
|
-
music, _ =
|
|
142
|
-
|
|
167
|
+
# 真实配乐(musicgen 输出峰值 -6 dBFS,比"类人声"响 —— 正是需要侧链/兜底的场景)
|
|
168
|
+
music, _ = MG.render(6.0, bpm=110.0, key="Am", scale="minor",
|
|
169
|
+
mood="steady", seed=7)
|
|
143
170
|
music = np.asarray(music, dtype=np.float32)
|
|
144
171
|
out = K.mix_film(voice.astype(np.float32), music, None, total=6.0, fade=0.0,
|
|
145
172
|
music_gain_db=-12.0, voice_duck_db=6.0,
|
|
@@ -165,7 +192,7 @@ def _duck_default():
|
|
|
165
192
|
|
|
166
193
|
def main(argv=None):
|
|
167
194
|
_utf8_stdio()
|
|
168
|
-
ap = argparse.ArgumentParser(description="
|
|
195
|
+
ap = argparse.ArgumentParser(description="配乐/音效相似度诊断与验收")
|
|
169
196
|
ap.add_argument("--out", default="", help="把对照样本写成 wav 到这个目录")
|
|
170
197
|
ap.add_argument("--dur", type=float, default=20.0, help="配乐对照时长(秒)")
|
|
171
198
|
ap.add_argument("--strict", action="store_true", help="按契约 §5 阈值判定,失败 exit 1")
|
|
@@ -174,29 +201,29 @@ def main(argv=None):
|
|
|
174
201
|
import sfxkit as SX
|
|
175
202
|
|
|
176
203
|
print("=" * 72)
|
|
177
|
-
print("A. 跨片配乐(差异来源 =
|
|
204
|
+
print("A. 跨片配乐(差异来源 = 编排参数 / bpm / 调 / 内容指纹 / seed)")
|
|
178
205
|
print("=" * 72)
|
|
179
|
-
a1 = _music(args.dur, "
|
|
180
|
-
a2 = _music(args.dur, "
|
|
206
|
+
a1 = _music(args.dur, "steady", "content-a")
|
|
207
|
+
a2 = _music(args.dur, "steady", "content-b")
|
|
181
208
|
a3 = _music(args.dur, "magazine", "content-a")
|
|
182
|
-
a4 = _music(args.dur, "
|
|
209
|
+
a4 = _music(args.dur, "steady", "content-a") # 同指纹复渲染,验证确定性
|
|
183
210
|
same_style = _corr(a1, a2)
|
|
184
211
|
cross_style = _corr(a1, a3)
|
|
185
212
|
rms_a1 = _rms(a1)
|
|
186
213
|
print(" 配乐非静音(RMS > -60 dBFS) : %s (rms=%.4f,静音时相关系数无定义)"
|
|
187
214
|
% (rms_a1 > 0.001, rms_a1))
|
|
188
|
-
print("
|
|
215
|
+
print(" 同参数·同bpm·同调,只换内容指纹 : corr = %.3f (目标 <= %.2f)"
|
|
189
216
|
% (same_style, THRESH_A_SAME_STYLE_MAX))
|
|
190
|
-
print("
|
|
217
|
+
print(" 换编排参数(steady -> magazine) : corr = %.3f (目标 <= %.2f)"
|
|
191
218
|
% (cross_style, THRESH_A_CROSS_STYLE_MAX))
|
|
192
219
|
print(" 同指纹两次渲染逐样本相同 : %s" % bool(np.array_equal(a1, a4)))
|
|
193
220
|
if args.strict:
|
|
194
221
|
# A0 是前置条件:若配乐渲染成静音/常量,A1/A2 的相关系数没有定义(NaN),
|
|
195
222
|
# 必须显式判 FAIL,而不是让 NaN 比较悄悄变成 False 后只剩一句 corr=nan。
|
|
196
223
|
_ok("A0 配乐非静音", rms_a1 > 0.001, "rms=%.4f" % rms_a1)
|
|
197
|
-
_ok("A1
|
|
224
|
+
_ok("A1 同参数换指纹可区分", same_style <= THRESH_A_SAME_STYLE_MAX,
|
|
198
225
|
"corr=%.3f" % same_style)
|
|
199
|
-
_ok("A2
|
|
226
|
+
_ok("A2 换编排参数更不像", cross_style <= THRESH_A_CROSS_STYLE_MAX,
|
|
200
227
|
"corr=%.3f" % cross_style)
|
|
201
228
|
_ok("A3 配乐可复现", bool(np.array_equal(a1, a4)), "同指纹逐样本一致")
|
|
202
229
|
|
|
@@ -211,7 +238,7 @@ def main(argv=None):
|
|
|
211
238
|
legacy_same = bool(np.array_equal(b_legacy1, b_legacy2))
|
|
212
239
|
cross_diff = not bool(np.array_equal(b_new_a, b_new_b))
|
|
213
240
|
repeat_same = bool(np.array_equal(b_new_a, b_new_a2))
|
|
214
|
-
print(" legacy(不传 style/fp)两次相同 : %s (必须 True
|
|
241
|
+
print(" legacy(不传 style/fp)两次相同 : %s (必须 True,逐样本兼容)" % legacy_same)
|
|
215
242
|
print(" 不同内容指纹 -> 音效轨不同 : %s (必须 True)" % cross_diff)
|
|
216
243
|
print(" 同内容指纹两次相同 : %s (必须 True,可复现)" % repeat_same)
|
|
217
244
|
if args.strict:
|
|
@@ -237,7 +264,7 @@ def main(argv=None):
|
|
|
237
264
|
|
|
238
265
|
print()
|
|
239
266
|
print("=" * 72)
|
|
240
|
-
print("D.
|
|
267
|
+
print("D. 旁白优先:人声与背景的响度差")
|
|
241
268
|
print("=" * 72)
|
|
242
269
|
snr_db, base_db, _duck_with_guard = _voice_priority_snr()
|
|
243
270
|
# 侧链让位单独量一次(guard_snr_db=0):兜底的整体补压也是"背景变低",
|
|
@@ -256,10 +283,10 @@ def main(argv=None):
|
|
|
256
283
|
if args.out:
|
|
257
284
|
out = Path(args.out)
|
|
258
285
|
out.mkdir(parents=True, exist_ok=True)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
286
|
+
import musicgen as MG
|
|
287
|
+
MG.save(out / "music_steady_a.wav", a1)
|
|
288
|
+
MG.save(out / "music_steady_b.wav", a2)
|
|
289
|
+
MG.save(out / "music_magazine.wav", a3)
|
|
263
290
|
for t in ("whoosh", "riser", "impact"):
|
|
264
291
|
SX.save(out / ("sfx_%s.wav" % t), SX.render(t, seed=1000))
|
|
265
292
|
print("\n对照样本已写入: %s" % out)
|
|
@@ -276,4 +303,4 @@ def main(argv=None):
|
|
|
276
303
|
|
|
277
304
|
|
|
278
305
|
if __name__ == "__main__":
|
|
279
|
-
raise SystemExit(main())
|
|
306
|
+
raise SystemExit(main())
|
package/tools/ci_check.py
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
跑法:python tools/ci_check.py [-v]
|
|
5
5
|
|
|
6
6
|
它检查的是**这个仓库作为"可分发的技能插件"的完整性**,而不是视频能不能出片
|
|
7
|
-
(出片要靠 selfcheck.py + 真跑一遍 render.py,见 SKILL.md §
|
|
7
|
+
(出片要靠 selfcheck.py + 真跑一遍 render.py,见 SKILL.md §13):
|
|
8
8
|
|
|
9
9
|
1 语法:scripts/ + assets/ + tools/ 下的 .py 全部能编译(SyntaxWarning 也算失败)
|
|
10
10
|
2 JS 语法:根目录与 assets/web/ 的 .js 通过 node --check
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
10 CI workflow:step 名的裸冒号(会让整份 workflow 静默不跑),
|
|
22
22
|
以及 run 里 python <路径>.py 指向的文件必须存在
|
|
23
23
|
11 体积:单个文件不超过 400 KB(技能要能塞进上下文与包里)
|
|
24
|
-
12 film.json 契约:schemas/film.schema.json + scripts/filmcheck.py
|
|
24
|
+
12 film.json 契约:schemas/film.schema.json + scripts/filmcheck.py 校验夹具(规则只留一份)
|
|
25
25
|
|
|
26
26
|
三档结论(与技能自己的门禁账本同口径):PASS = 这次真的查了且通过;FAIL = 查了没过;
|
|
27
27
|
SKIP = **这次没执行**(比如本地没装 node / npm),**不等于通过**。
|
|
@@ -49,17 +49,23 @@ REFS = SKILL / "references"
|
|
|
49
49
|
|
|
50
50
|
# 技能允许的三方依赖(与 README「环境前提」一一对应;加东西必须先改文档)
|
|
51
51
|
ALLOWED_THIRD_PARTY = {"numpy", "PIL", "cv2", "scipy", "edge_tts", "playwright",
|
|
52
|
-
#
|
|
52
|
+
# ASR 回读门禁(可选,缺了 qaasr 自动降级到 ffmpeg whisper / skipped)
|
|
53
53
|
"faster_whisper"}
|
|
54
54
|
# 同目录同级模块(拷走整个目录后仍能互相 import)
|
|
55
55
|
LOCAL_MODULES = {"filmkit", "sfxkit", "scorekit", "webengine", "qa_frames", "render", "selfcheck",
|
|
56
56
|
"stylekit", "voicekit", "scenes", "scenes_demo",
|
|
57
|
-
#
|
|
57
|
+
# 门禁账本 / 交付报告 / 签核、阶段新鲜度守卫、film.json 契约校验器
|
|
58
58
|
"gates", "stagewatch", "filmcheck",
|
|
59
|
-
#
|
|
59
|
+
# 渲染前资源闸门、全片 VMAF/SSIM 门禁、ASR 回读门禁
|
|
60
60
|
"preflight", "qavmaf", "qaasr",
|
|
61
|
-
#
|
|
62
|
-
"grammar"
|
|
61
|
+
# 场景语法(内容 -> 构图选型)。纯标准库,可被 scenes.py 反向 import
|
|
62
|
+
"grammar",
|
|
63
|
+
# 设计 DSL / 离线预览 / 提案器
|
|
64
|
+
"designkit", "previewkit", "propose",
|
|
65
|
+
# 没有 presetkit(随预设目录一起移除);这里不能再列它,
|
|
66
|
+
# 否则 ci_check 会去 import 一个不存在的模块
|
|
67
|
+
# 参数化配乐编排器(不提供曲风表,按参数现场编排)
|
|
68
|
+
"musicgen"}
|
|
63
69
|
MAX_FILE_BYTES = 400 * 1024
|
|
64
70
|
# 体积检查忽略的目录后缀:.team 是本地调研/自测工作区(gitignore),不随包发布;
|
|
65
71
|
# 媒体文件本来就该大(样例片/参考视频),不该拿"能不能塞进上下文"的尺子量。
|
|
@@ -170,7 +176,7 @@ def t_json():
|
|
|
170
176
|
assert not bad_badges, ("README 徽章版本 %s 与 package.json version %s 不一致"
|
|
171
177
|
% ("/".join(bad_badges), ver))
|
|
172
178
|
# CHANGELOG 的第一条标题也必须是当前版本:以前只查 README 徽章,于是"发了新版本但
|
|
173
|
-
# CHANGELOG 顶部还是上一版"
|
|
179
|
+
# CHANGELOG 顶部还是上一版"没人发现(真发生过)。
|
|
174
180
|
changelog = ROOT / "CHANGELOG.md"
|
|
175
181
|
assert changelog.is_file(), "缺 CHANGELOG.md"
|
|
176
182
|
m = re.search(r"^##\s+(\d+\.\d+\.\d+)", changelog.read_text(encoding="utf-8"), re.M)
|
|
@@ -254,8 +260,8 @@ def t_pack():
|
|
|
254
260
|
missing = [m for m in must if m not in files]
|
|
255
261
|
assert not missing, "包内缺文件:%s" % ", ".join(missing)
|
|
256
262
|
# 目录部分改成**派生式**:skills/ schemas/ locale/ tools/ docs/ 下磁盘上有的(除缓存/字节码)
|
|
257
|
-
#
|
|
258
|
-
#
|
|
263
|
+
# 必须全部进包。手写 must 列表对新增文件天然免疫:gates.py / stagewatch.py /
|
|
264
|
+
# preflight.py / qavmaf.py / qaasr.py / tools/golden_frames.py 都曾被漏在列表外——
|
|
259
265
|
# "必需文件必须在包里"这条检查对**新增文件天然免疫**,等于只保护了第一版写下的那些路径。
|
|
260
266
|
shipped_dirs = ("skills", "schemas", "locale", "tools", "docs")
|
|
261
267
|
on_disk = []
|
|
@@ -328,7 +334,7 @@ def t_doc_refs():
|
|
|
328
334
|
# 第三种:带占位符的路径(<SKILL_DIR>/... 与 <REPO>/...)。ci_check 之前只认 scripts/|assets/|references/
|
|
329
335
|
# 前缀,SKILL.md 里 <SKILL_DIR>/../../../tools/estimate.py 这种写错两级相对层级的命令一直没人查——
|
|
330
336
|
# 实测它指向仓库上一级,是条**跑不通的命令**。语义:<SKILL_DIR> = skills/video-studio/,
|
|
331
|
-
# <REPO> = 包根(仓库根)。注意**不能**把它们当成"当前工作目录"
|
|
337
|
+
# <REPO> = 包根(仓库根)。注意**不能**把它们当成"当前工作目录":把技能解到 myfilm/ 下面后,
|
|
332
338
|
# 那里的 skills 与 tools 都不存在,但文档里的占位符指的是包内位置,仍然应当成立。
|
|
333
339
|
ph_bad = []
|
|
334
340
|
ph_re = re.compile(r"(<SKILL_DIR>|<REPO>)/([A-Za-z0-9_.\-/{}*]+)")
|
|
@@ -441,7 +447,7 @@ def t_py39():
|
|
|
441
447
|
def t_md_fences():
|
|
442
448
|
"""每篇 .md 的代码围栏必须成对(按围栏状态机判,不是数出现次数)。
|
|
443
449
|
|
|
444
|
-
|
|
450
|
+
早期这里写的是 startswith("\\`\\`\\`")——那是一个无效转义,Python 把反斜杠原样保留,
|
|
445
451
|
于是永远匹配不到真实的 ``` 行,检查恒过("看着在查,其实查不出",正是本仓库最怕的形态)。
|
|
446
452
|
现在改成状态机:开围栏记下字符与长度,闭合围栏必须同字符、长度不小于开口、且不带信息串。
|
|
447
453
|
"""
|
|
@@ -532,9 +538,9 @@ def t_film_contract():
|
|
|
532
538
|
cwd=str(scripts), capture_output=True, text=True,
|
|
533
539
|
encoding="utf-8", errors="replace", timeout=120)
|
|
534
540
|
out = (p.stdout or "") + (p.stderr or "")
|
|
535
|
-
assert p.returncode == 0, ("
|
|
541
|
+
assert p.returncode == 0, ("夹具 film.json 没过契约校验(exit=%d):\n%s"
|
|
536
542
|
% (p.returncode, out.strip()[-600:]))
|
|
537
|
-
return "
|
|
543
|
+
return "夹具 film.json 通过契约校验;schema %d 字节" % len(schema.read_text(encoding="utf-8"))
|
|
538
544
|
|
|
539
545
|
def main(argv=None) -> int:
|
|
540
546
|
ap = argparse.ArgumentParser(description="仓库级体检(不需要 ffmpeg / 字体 / 网络)")
|
package/tools/estimate.py
CHANGED
|
@@ -40,21 +40,25 @@ import unicodedata
|
|
|
40
40
|
from pathlib import Path
|
|
41
41
|
|
|
42
42
|
# ============================================================================
|
|
43
|
-
#
|
|
43
|
+
# 基准常量(换机请重测后回填,别把这里的数字当通用真理)
|
|
44
44
|
# ============================================================================
|
|
45
45
|
|
|
46
46
|
W, H, FPS = 1920, 1080, 30 # filmkit.W/H/FPS:本项目写死,film.json 改不了
|
|
47
|
-
PIL_FPS = (40.0, 80.0) # PIL 逐帧渲染速度:中等复杂度 40–54 fps /
|
|
47
|
+
PIL_FPS = (40.0, 80.0) # PIL 逐帧渲染速度:中等复杂度 40–54 fps / 夹具片 68–82 fps
|
|
48
48
|
PARALLEL_EFF = 0.5 # 分块并行的**实测效率**:2 进程只快 1.5×,不是 2×(见 CALIBRATION)
|
|
49
49
|
WEB_SEC_PER_FRAME = (0.080, 0.130) # web 引擎:Edge 无头 PNG 截图 80–130 ms/帧
|
|
50
50
|
WEB_STARTUP_SEC = 6.0 # 浏览器冷启动 + CDP 连接(12 s 片 28–30 s 含启动)
|
|
51
51
|
X264_FPS = 141.0 # libx264 -preset medium -crf 17 ≈ 4.7× 实时(仅 reencode_video 用)
|
|
52
52
|
TTS_SEC_PER_LINE = (2.0, 4.0) # edge-tts:每句每 worker 的墙钟(联网,波动大):实测 2.2 s,留到 4.0
|
|
53
|
-
|
|
54
|
-
#
|
|
55
|
-
#
|
|
53
|
+
# 配乐耗时锚点 (片长 s, 耗时低, 耗时高)。
|
|
54
|
+
#
|
|
55
|
+
# musicgen 实测值:30 s / 60 s / 120 s 分别 0.94 / 1.93 / 3.68 s(≈31× 实时)。
|
|
56
|
+
# 参数化编排器是单遍事件合成,实测抖动也小(同参数逐采样确定,耗时只随片长线性涨);
|
|
57
|
+
# 早期逐层展开事件、多次 IIR 的配乐引擎实测 29 s 片要 3.6–6.5 s。
|
|
58
|
+
# 区间仍给 0.7–1.5 系数包络:CI 机器与共享 CPU 会慢,报小了会让人以为卡住。
|
|
59
|
+
SCORE_REFS = ((29.0, 0.9, 1.6), (58.0, 1.8, 3.0), (389.0, 11.5, 19.0))
|
|
56
60
|
SFX_SEC_PER_CUE = 0.20 # 单条短音效 合成+落位(24 配方单次 ≤1.5 s 是上限口径)
|
|
57
|
-
SFX_MIN_SEC = 0.8 # 实测 18 条 cue
|
|
61
|
+
SFX_MIN_SEC = 0.8 # 实测 18 条 cue 的夹具片 sfx 墙钟 0.8 s(含 ffmpeg 写盘)
|
|
58
62
|
LAYOUT_BASE, LAYOUT_PER_LINE = 0.05, 0.01 # 排轴 + srt/ass + 安全区/字形自查(实测 29 s 片 <0.15 s)
|
|
59
63
|
CUES_BASE, CUES_PER_LINE = 0.05, 0.005 # 提示点落盘 + 排序(实测 29 s 片 <0.15 s)
|
|
60
64
|
AUDIO_BASE, AUDIO_PER_SEC = 0.6, 0.012 # numpy 总线按采样点叠加 + 写 wav
|
|
@@ -65,12 +69,12 @@ MUX_PER_SEC = 0.045 # 每片长秒(两遍 loudnorm 解码音
|
|
|
65
69
|
QA_BASE_SEC = (1.0, 3.0) # 固定开销:联系表 + 参考帧重渲染
|
|
66
70
|
QA_SEC_PER_SEC = (0.12, 0.25) # 随片长:帧级深检流式解码 + 响度曲线
|
|
67
71
|
UNMEASURED_BAND = 0.30 # 没有实测的阶段(layout/cues/sfx/audio)给 ±30% 的区间,别假装精确
|
|
68
|
-
#
|
|
72
|
+
# G11(全片 VMAF/SSIM):参考帧由场景函数**重渲一遍**再喂 libvmaf,成本跟帧数走、
|
|
69
73
|
# 与 frames 阶段同量级。实测口径差异极大:qavmaf 作者 29 s 片 12.0 s(≈0.40× 片长);
|
|
70
|
-
# SKILL.md §12
|
|
74
|
+
# SKILL.md §12 同一部夹具片 14.2 s;同机另一轮负载下 28.8 s。这里取 0.40–1.00× 片长
|
|
71
75
|
# (下限=干净机、上限=争 CPU 的长片外推),固定开销单独列 1–2 s。
|
|
72
|
-
# 注:
|
|
73
|
-
#
|
|
76
|
+
# 注:1.0–1.6× 这类没有实测支撑的系数会把 29 s 片算成 31–50 s(实际 12–29 s),
|
|
77
|
+
# 预演总量系统性偏大近 2 倍——"拍脑袋的悲观系数"比低估更糟:排期会按假的数字走。
|
|
74
78
|
VMAF_BASE_SEC = (1.0, 2.0) # 起渲染进程 + 参考帧管线搭建
|
|
75
79
|
VMAF_SEC_PER_SEC = (0.40, 1.00) # 每片长秒(参考帧重渲 + libvmaf 逐帧比)
|
|
76
80
|
|
|
@@ -80,8 +84,8 @@ CALIBRATION = dict(
|
|
|
80
84
|
"SKILL.md §12 记录的作者机是 Ryzen AI 9 HX 370 / 12C24T / Radeon 890M"
|
|
81
85
|
"(是否同一台未核实,只保证这些数字是在上述 24 逻辑核机器上测的)"),
|
|
82
86
|
measured_at="2026-11-10",
|
|
83
|
-
sdk="video-studio
|
|
84
|
-
film="29.011 s / 870 帧 /
|
|
87
|
+
sdk="video-studio scripts(ffmpeg 自带 x264)",
|
|
88
|
+
film="29.011 s / 870 帧 / 测试样片(assets/film.json + assets/scenes.py,仅用于基准校准)",
|
|
85
89
|
method="render.py --stage all --jobs 2,逐 stage 取 [time] 行墙钟;同片重复 2 次",
|
|
86
90
|
observed=dict(tts=2.2, layout=0.0, cues=0.0, score=6.3, sfx=0.8, audio=0.7,
|
|
87
91
|
frames=9.7, mux=1.9, qa=6.8, total=28.4),
|
|
@@ -89,14 +93,14 @@ CALIBRATION = dict(
|
|
|
89
93
|
" score 58 s 片 4.5–5.0 s; qa 6.2–6.8 s",
|
|
90
94
|
)
|
|
91
95
|
CPS = 4.2 # 每秒约 4.2 字(voicekit 校准 3.84–4.61 cps 的中位口径)
|
|
92
|
-
DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR = 0.62, 0.92, 3.0 #
|
|
96
|
+
DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR = 0.62, 0.92, 3.0 # body 基准
|
|
93
97
|
CORES = 12 # HX 370:12C/24T;jobs > 12 不再线性加速
|
|
94
98
|
TTS_BYTES_PER_LINE = 32 * 1024 # 单句 mp3 缓存(4 s 语音约 24 KB,留余量)
|
|
95
99
|
WAV_BYTES_PER_SEC = 48000 * 2 * 2 # 48 kHz 立体声 PCM16 = 192 KB/s
|
|
96
100
|
VIDEO_MBPS = (1.2, 3.0) # crf17 @1080p30 图文内容(暗底大字 0.5–1 Mbps 属正常)
|
|
97
101
|
AUDIO_MBPS = 0.192 # AAC 192 kbps
|
|
98
102
|
QA_DISK_BYTES = 12 * 1024 * 1024 # 联系表 + 静帧 + deep.png
|
|
99
|
-
BASELINE_NOTE = ("
|
|
103
|
+
BASELINE_NOTE = ("区间来自本机实测(机型/样本见下方 calibration;"
|
|
100
104
|
"SKILL.md §12 记录的作者机是 Ryzen AI 9 HX 370 / 12C24T);换机请先重测再采信")
|
|
101
105
|
|
|
102
106
|
STAGE_LABELS = {
|
|
@@ -109,7 +113,7 @@ STAGE_LABELS = {
|
|
|
109
113
|
"frames": "逐帧渲染+编码",
|
|
110
114
|
"mux": "合成成片",
|
|
111
115
|
"qa": "质检+抽帧",
|
|
112
|
-
#
|
|
116
|
+
# 交付前的三道门禁单独列(它们的成本不能藏进 qa 里)
|
|
113
117
|
"preflight": "渲染前预检",
|
|
114
118
|
"vmaf": "全片VMAF/SSIM",
|
|
115
119
|
"asr": "ASR回读",
|
|
@@ -152,8 +156,9 @@ def load_script(path, as_json=False):
|
|
|
152
156
|
"""读剧本;坏输入给一句话错误(退出码 2),不甩 traceback。"""
|
|
153
157
|
p = Path(path).expanduser()
|
|
154
158
|
if not p.exists():
|
|
155
|
-
_die("找不到剧本 %s(--script
|
|
156
|
-
|
|
159
|
+
_die("找不到剧本 %s(--script 指向你自己的 film.json;"
|
|
160
|
+
"仓库里只有一个用于测试的样例 skills/video-studio/assets/film.json,"
|
|
161
|
+
"它不是要抄的模板)" % p, as_json)
|
|
157
162
|
try:
|
|
158
163
|
raw = p.read_text(encoding="utf-8")
|
|
159
164
|
except OSError as e:
|
|
@@ -308,7 +313,7 @@ def score_seconds(total):
|
|
|
308
313
|
def _speedup(jobs):
|
|
309
314
|
"""分块并行的**有效加速比**:不是 jobs 倍。
|
|
310
315
|
|
|
311
|
-
实测(870
|
|
316
|
+
实测(870 帧夹具片):jobs=1 → 14.37 s、jobs=2 → 9.53 s,只有 1.51×。
|
|
312
317
|
原因很实在:父进程要 spawn 子进程、每段都要重新 import scenes.py / 解析主题、
|
|
313
318
|
落盘 mux 串行、磁盘写带宽争用。按 jobs 线性折算会持续低估长片(越长的片分块越多)。
|
|
314
319
|
这里用 1 + (jobs-1)*PARALLEL_EFF 的线性模型:比理论值保守,实测 2 进程刚好对上。
|
|
@@ -420,7 +425,7 @@ def build_report(script_path, script, args):
|
|
|
420
425
|
_warn("engine=%r 不认识(只能是 pil / web),按 pil 预演" % engine)
|
|
421
426
|
engine = "pil"
|
|
422
427
|
|
|
423
|
-
#
|
|
428
|
+
# 画幅/帧率可以按片配置(只允许 16:9 横屏 + 下采样输出,见 filmkit.video_plan)。
|
|
424
429
|
# 预演必须跟着走,否则 720p 片会被按 1080p 估帧数与磁盘。
|
|
425
430
|
try:
|
|
426
431
|
import sys as _sys
|
|
@@ -449,7 +454,7 @@ def build_report(script_path, script, args):
|
|
|
449
454
|
source_note = "真实帧数:%s" % tl_path
|
|
450
455
|
else:
|
|
451
456
|
total, _scenes = estimate_timeline(script)
|
|
452
|
-
#
|
|
457
|
+
# 没有 timeline.json 时也必须用 film.json 的 video.fps —— 定死 30 会让
|
|
453
458
|
# 24/25/50/60 fps 的片子帧数、磁盘、分块全部按 30 fps 算(24 fps 高估 25%,
|
|
454
459
|
# 60 fps 低估一半),而 render.py 的 build_timeline(fps=vp["fps"]) 用的是真实值。
|
|
455
460
|
fps = int(_vp["fps"])
|
|
@@ -479,7 +484,7 @@ def build_report(script_path, script, args):
|
|
|
479
484
|
n_cues = len(sfx_cfg.get("cues") or [])
|
|
480
485
|
sfx_on = bool(sfx_cfg.get("enabled", True)) and n_cues > 0
|
|
481
486
|
# 配乐口径对齐 filmkit.music_plan:mode=off/none 或 enabled=false 才算关;
|
|
482
|
-
# 只有 mode=score(或没写 mode 但写了 bpm
|
|
487
|
+
# 只有 mode=score(或没写 mode 但写了 bpm)才会真跑配乐引擎(musicgen)。
|
|
483
488
|
mode = str(mus.get("mode") or "").strip().lower()
|
|
484
489
|
has_bpm = "bpm" in mus or "bpm" in script
|
|
485
490
|
music_on = bool(mus.get("enabled", True)) and mode not in ("off", "none")
|
|
@@ -510,8 +515,8 @@ def build_report(script_path, script, args):
|
|
|
510
515
|
mux_lo, mux_hi = mux_seconds_pair(frames, jobs_eff, total, script)
|
|
511
516
|
qa_lo = max(0.5, QA_BASE_SEC[0] + total * QA_SEC_PER_SEC[0])
|
|
512
517
|
qa_hi = max(qa_lo, QA_BASE_SEC[1] + total * QA_SEC_PER_SEC[1])
|
|
513
|
-
#
|
|
514
|
-
# 实测口径(29.0 s
|
|
518
|
+
# 三个可选门禁:预检(固定小开销)、全片 VMAF/SSIM、ASR 回读。
|
|
519
|
+
# 实测口径(29.0 s 夹具片):preflight 0.4 s;VMAF full 12–29 s(0.40–1.00× 片长,
|
|
515
520
|
# 参考帧重渲与 libvmaf 都在里面,见 VMAF_* 常量注释);ASR small ≈ 6–10 s。
|
|
516
521
|
# 这三项全在**交付前**跑,不写进预演就等于"预演说 40 s、实际 90 s"——所以单独列 stage。
|
|
517
522
|
qcfg = script.get("qa") or {}
|
|
@@ -570,7 +575,7 @@ def build_report(script_path, script, args):
|
|
|
570
575
|
dict(stage="score", lo=score_lo, hi=score_hi, measured=bool(score_on),
|
|
571
576
|
note=("以实测点 29 s≈3.6–6.3 s / 58 s≈4.5–5.0 s 为锚(--stage all 里与其它阶段争 CPU 会更慢;389 s 只是外推锚点)"
|
|
572
577
|
if score_on else
|
|
573
|
-
("mode=%s
|
|
578
|
+
("mode=%s:不跑配乐引擎(%s)"
|
|
574
579
|
% (mode or "-", "外部配乐/和弦床/关掉配乐")))),
|
|
575
580
|
dict(stage="sfx", lo=sfx_lo, hi=sfx_hi, measured=False,
|
|
576
581
|
note=("%d 条音效(实测 18 条 0.8 s;单条上限口径 ≤1.5 s,长环境声 wind/rain 另计)"
|
|
@@ -624,7 +629,7 @@ def build_report(script_path, script, args):
|
|
|
624
629
|
"source": source,
|
|
625
630
|
"source_note": source_note,
|
|
626
631
|
"video": {"title": script.get("title"),
|
|
627
|
-
#
|
|
632
|
+
# 内部构图恒为 1080p,交付尺寸可以更小(_vp 从 film.json 的 video 块来)
|
|
628
633
|
"width": _vp["w"], "height": _vp["h"],
|
|
629
634
|
"out_width": _vp["out_w"], "out_height": _vp["out_h"],
|
|
630
635
|
"fps": fps, "frames": frames,
|
|
@@ -654,8 +659,8 @@ def build_report(script_path, script, args):
|
|
|
654
659
|
"advice": bottleneck_advice(worst["stage"], engine, jobs_eff)},
|
|
655
660
|
"assumptions": [
|
|
656
661
|
"内部构图恒为 1920x1080;交付尺寸/帧率可写 film.json 的 video 块"
|
|
657
|
-
"
|
|
658
|
-
"lead=%.2f / tail=%.2f / min_dur=%.1f(
|
|
662
|
+
"(只允许 16:9 横屏且只能下采样;本次按 %d fps 估)" % fps,
|
|
663
|
+
"lead=%.2f / tail=%.2f / min_dur=%.1f(body 基准;剧本写了就以剧本为准)"
|
|
659
664
|
% (DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR),
|
|
660
665
|
"tts 耗时按 %d 句 ÷ workers × %.1f–%.1f s(每句每 worker);实际受网络波动影响"
|
|
661
666
|
% (n, TTS_SEC_PER_LINE[0], TTS_SEC_PER_LINE[1]),
|
|
@@ -663,7 +668,7 @@ def build_report(script_path, script, args):
|
|
|
663
668
|
% (PARALLEL_EFF, CORES),
|
|
664
669
|
"mux 默认 -c:v copy,只跟片长有关;reencode_video=true 才按帧数重编",
|
|
665
670
|
"layout/cues/audio/sfx 给 ±%d%% 经验区间(无单阶段实测)" % int(UNMEASURED_BAND * 100),
|
|
666
|
-
"
|
|
671
|
+
"可选门禁口径:preflight 0.4 s 固定;vmaf ≈ %.2f–%.2f× 片长 + %.1f–%.1f s"
|
|
667
672
|
"(参考帧重渲染 + libvmaf;同机负载不同实测差近 2×);asr ≈ 0.18–0.35× 片长 + "
|
|
668
673
|
"3.5–6.5 s(faster-whisper small / CPU int8)"
|
|
669
674
|
% (VMAF_SEC_PER_SEC[0], VMAF_SEC_PER_SEC[1], VMAF_BASE_SEC[0], VMAF_BASE_SEC[1]),
|
|
@@ -689,7 +694,7 @@ def print_report(rep):
|
|
|
689
694
|
"会自动改用真实帧数。")
|
|
690
695
|
style = v.get("style")
|
|
691
696
|
if style is None:
|
|
692
|
-
style = "(没写 style 字段 =
|
|
697
|
+
style = "(没写 style 字段 = legacy 行为)"
|
|
693
698
|
print(" 《%s》 片长 %.1f s / %d 帧 / %dx%d@%d / engine=%s / crf=%s preset=%s / style=%s"
|
|
694
699
|
% (v.get("title") or "(未写 title)", v["duration_s"], v["frames"], v["width"], v["height"],
|
|
695
700
|
v["fps"], v["engine"], v["crf"], v["preset"], style))
|