@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +473 -0
  2. package/README.md +263 -327
  3. package/THIRD_PARTY_NOTICES.md +2 -2
  4. package/package.json +9 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +31 -42
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1216 -1123
  9. package/skills/video-studio/assets/design_fixture.json +141 -0
  10. package/skills/video-studio/assets/film.json +2 -4
  11. package/skills/video-studio/assets/golden_frames.json +13 -10
  12. package/skills/video-studio/assets/scenes.py +153 -65
  13. package/skills/video-studio/assets/voice_loudness.json +16 -0
  14. package/skills/video-studio/references/appearance.md +123 -0
  15. package/skills/video-studio/references/audio-variation.md +74 -93
  16. package/skills/video-studio/references/audio.md +64 -66
  17. package/skills/video-studio/references/change-impact.md +5 -5
  18. package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +16 -18
  19. package/skills/video-studio/references/frame-qa.md +48 -16
  20. package/skills/video-studio/references/pipeline.md +4 -4
  21. package/skills/video-studio/references/pitfalls.md +22 -3
  22. package/skills/video-studio/references/proposal.md +160 -0
  23. package/skills/video-studio/references/quality-gates.md +11 -11
  24. package/skills/video-studio/references/scene-authoring.md +2 -2
  25. package/skills/video-studio/references/scene-grammar.md +40 -42
  26. package/skills/video-studio/references/sfx-design.md +8 -6
  27. package/skills/video-studio/references/web-engine.md +8 -8
  28. package/skills/video-studio/references/word-timeline.md +9 -9
  29. package/skills/video-studio/requirements.txt +9 -13
  30. package/skills/video-studio/scripts/designkit.py +2115 -0
  31. package/skills/video-studio/scripts/filmcheck.py +22 -48
  32. package/skills/video-studio/scripts/filmkit.py +2980 -2904
  33. package/skills/video-studio/scripts/gates.py +4 -4
  34. package/skills/video-studio/scripts/grammar.py +73 -13
  35. package/skills/video-studio/scripts/musicgen.py +2042 -0
  36. package/skills/video-studio/scripts/preflight.py +14 -7
  37. package/skills/video-studio/scripts/previewkit.py +848 -0
  38. package/skills/video-studio/scripts/propose.py +825 -0
  39. package/skills/video-studio/scripts/qa_frames.py +73 -39
  40. package/skills/video-studio/scripts/qaasr.py +26 -7
  41. package/skills/video-studio/scripts/qavmaf.py +89 -23
  42. package/skills/video-studio/scripts/render.py +545 -231
  43. package/skills/video-studio/scripts/scorekit.py +55 -1755
  44. package/skills/video-studio/scripts/selfcheck.py +815 -215
  45. package/skills/video-studio/scripts/sfxkit.py +27 -26
  46. package/skills/video-studio/scripts/stagewatch.py +24 -8
  47. package/skills/video-studio/scripts/stylekit.py +207 -888
  48. package/skills/video-studio/scripts/voicekit.py +508 -387
  49. package/skills/video-studio/scripts/webengine.py +71 -72
  50. package/tools/audio_similarity.py +64 -37
  51. package/tools/ci_check.py +20 -14
  52. package/tools/estimate.py +34 -29
  53. package/tools/golden_frames.py +72 -28
  54. package/tools/silhouette_qa.py +520 -168
  55. package/docs/demo-pil-sheet.png +0 -0
  56. package/docs/styles/demo-results.json +0 -76
  57. package/docs/styles/sheet-8-styles.png +0 -0
  58. package/docs/styles/sheet-ai.png +0 -0
  59. package/docs/styles/sheet-before-after.png +0 -0
  60. package/docs/styles/sheet-craft.png +0 -0
  61. package/docs/styles/sheet-night.png +0 -0
  62. package/skills/video-studio/assets/score_demo.json +0 -307
  63. 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 跨片配乐(v0.7):同风格/同 bpm/同调、只换内容指纹 -> 相关性(目标 <= 0.85);
13
- 换风格 -> 相关性(目标 <= 0.52,且不能比改造前更接近)
14
- B 跨片音效(v0.7):同 cue 序列,legacy(不传 style/content_fp)必须逐样本相同;
12
+ A 跨片配乐(musicgen 参数化编排):
13
+ 同参数/同 bpm/同调、只换内容指纹 -> 相关性(目标 <= 0.85);
14
+ 换编排参数(mood + 音色) -> 相关性(目标 <= 0.52)
15
+ B 跨片音效:同 cue 序列,legacy(不传 style/content_fp)必须逐样本相同;
15
16
  不同 content_fp 必须不同(否则"跨片音效都一样"没被修掉)
16
- C 片内分离(v0.7):duck 默认深度(目标 >= 3.5 dB)与配乐/音效的频谱质心对照
17
- D 旁白优先(v0.7.1):人声段 旁白/背景 响度差(目标 >= 12 dB)+ 侧链让位 >= 4 dB
17
+ C 片内分离:duck 默认深度(目标 >= 3.5 dB)与配乐/音效的频谱质心对照
18
+ D 旁白优先:人声段 旁白/背景 响度差(目标 >= 12 dB)+ 侧链让位 >= 4 dB
18
19
 
19
- 本脚本在改造前是"诊断工具",改造后是"验收工具":--strict 会逐条对阈值判定。
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 # 换风格的相关性上限(改造前 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,等于这条门禁白设)。
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, 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)
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
- """量"人声比背景响多少":用真实 scorekit 配乐 + 类人声,按 st_audio 的默认参数混音。
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 scorekit as SK
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
- # 真实配乐(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")
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="配乐/音效相似度诊断(v0.7 验收)")
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. 跨片配乐(差异来源 = 风格 / bpm / 调 / 内容指纹 / seed)")
204
+ print("A. 跨片配乐(差异来源 = 编排参数 / bpm / 调 / 内容指纹 / seed)")
178
205
  print("=" * 72)
179
- a1 = _music(args.dur, "tech-deep", "content-a")
180
- a2 = _music(args.dur, "tech-deep", "content-b")
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, "tech-deep", "content-a") # 同指纹复渲染,验证确定性
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(" 同风格·同bpm·同调,只换内容指纹 : corr = %.3f (目标 <= %.2f)"
215
+ print(" 同参数·同bpm·同调,只换内容指纹 : corr = %.3f (目标 <= %.2f)"
189
216
  % (same_style, THRESH_A_SAME_STYLE_MAX))
190
- print(" 换风格(tech-deep -> magazine) : corr = %.3f (目标 <= %.2f)"
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 同风格换指纹可区分", same_style <= THRESH_A_SAME_STYLE_MAX,
224
+ _ok("A1 同参数换指纹可区分", same_style <= THRESH_A_SAME_STYLE_MAX,
198
225
  "corr=%.3f" % same_style)
199
- _ok("A2 换风格更不像", cross_style <= THRESH_A_CROSS_STYLE_MAX,
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,v0.4 兼容)" % legacy_same)
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. 旁白优先:人声与背景的响度差(v0.7.1)")
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
- 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)
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 §14):
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
- # v0.8:ASR 回读门禁(可选,缺了 qaasr 自动降级到 ffmpeg whisper / skipped)
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
- # v0.6.0:门禁账本 / 交付报告 / 签核、阶段新鲜度守卫、film.json 契约校验器
57
+ # 门禁账本 / 交付报告 / 签核、阶段新鲜度守卫、film.json 契约校验器
58
58
  "gates", "stagewatch", "filmcheck",
59
- # v0.8.0:渲染前资源闸门、全片 VMAF/SSIM 门禁、ASR 回读门禁
59
+ # 渲染前资源闸门、全片 VMAF/SSIM 门禁、ASR 回读门禁
60
60
  "preflight", "qavmaf", "qaasr",
61
- # v0.9.0:场景语法(内容 -> 构图选型)。纯标准库,可被 scenes.py 反向 import
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 顶部还是上一版"没人发现(历史真发生过,见 v0.7.2 的修复记录)。
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
- # 必须全部进包。以前这里是手写 must 列表,于是 v0.6 新增的 gates.py / stagewatch.py、
258
- # v0.8 新增的 preflight.py / qavmaf.py / qaasr.py / tools/golden_frames.py 都不在列表里——
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> = 包根(仓库根)。注意**不能**把它们当成"当前工作目录":quickstart 把技能拷进 myfilm/ 后,
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
- v0.7.1 之前这里写的是 startswith("\\`\\`\\`")——那是一个无效转义,Python 把反斜杠原样保留,
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, ("模板 film.json 没过契约校验(exit=%d):\n%s"
541
+ assert p.returncode == 0, ("夹具 film.json 没过契约校验(exit=%d):\n%s"
536
542
  % (p.returncode, out.strip()[-600:]))
537
- return "模板 film.json 通过契约校验;schema %d 字节" % len(schema.read_text(encoding="utf-8"))
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
- # 基准常量(v0.7.2 重新校准:换机请重测后回填,别把这里的数字当通用真理)
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 / 模板片 68–82 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
- SCORE_REFS = ((29.0, 3.6, 6.5), (58.0, 4.5, 6.5), (389.0, 8.0, 18.0))
54
- # ^ (片长 s, 耗时低, 耗时高):前两个点是本机实测(29 s / 58 s 各 3 次),
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 的模板片 sfx 墙钟 0.8 s(含 ffmpeg 写盘)
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
- # v0.8 G11(全片 VMAF/SSIM):参考帧由场景函数**重渲一遍**再喂 libvmaf,成本跟帧数走、
72
+ # G11(全片 VMAF/SSIM):参考帧由场景函数**重渲一遍**再喂 libvmaf,成本跟帧数走、
69
73
  # 与 frames 阶段同量级。实测口径差异极大:qavmaf 作者 29 s 片 12.0 s(≈0.40× 片长);
70
- # SKILL.md §12 同一部模板片 14.2 s;同机另一轮负载下 28.8 s。这里取 0.40–1.00× 片长
74
+ # SKILL.md §12 同一部夹具片 14.2 s;同机另一轮负载下 28.8 s。这里取 0.40–1.00× 片长
71
75
  # (下限=干净机、上限=争 CPU 的长片外推),固定开销单独列 1–2 s。
72
- # 注:v0.7.2 那套 1.0–1.6× 的系数没有 v0.8 实测支撑,把 29 s 片算成 31–50 s(实际 12–29 s),
73
- # 预演总量因此系统性偏大近 2 倍——这类"拍脑袋的悲观系数"比低估更糟:排期会按假的数字走。
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 v0.7.1 scripts(ffmpeg 自带 x264)",
84
- film="29.011 s / 870 帧 / 模板片(assets/film.json + assets/scenes.py)",
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 # v0.5 body 基准
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 = ("区间来自 v0.7.2 本机实测(机型/样本见下方 calibration;"
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
- # v0.8:交付前的三道新门禁单独列(它们的成本不能藏进 qa 里)
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 指到 film.json;模板在 skills/video-studio/assets/film.json)"
156
- % p, as_json)
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 帧模板片):jobs=1 → 14.37 s、jobs=2 → 9.53 s,只有 1.51×。
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
- # v0.8:画幅/帧率可以按片配置(只允许 16:9 横屏 + 下采样输出,见 filmkit.video_plan)。
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
- # v0.8:没有 timeline.json 时也必须用 film.json 的 video.fps —— 定死 30 会让
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)才会真跑 scorekit。
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
- # v0.8 的三个新门禁:预检(固定小开销)、全片 VMAF/SSIM、ASR 回读。
514
- # 实测口径(29.0 s 模板片):preflight 0.4 s;VMAF full 12–29 s(0.40–1.00× 片长,
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:不跑 scorekit(%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
- # v0.8:内部构图恒为 1080p,交付尺寸可以更小(_vp 从 film.json 的 video 块来)
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
- "(v0.8,只允许 16:9 横屏且只能下采样;本次按 %d fps 估)" % fps,
658
- "lead=%.2f / tail=%.2f / min_dur=%.1f(v0.5 body 基准;剧本写了就以剧本为准)"
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
- "v0.8 新增门禁口径:preflight 0.4 s 固定;vmaf ≈ %.2f–%.2f× 片长 + %.1f–%.1f s"
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 字段 = v0.4 legacy 行为)"
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))