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

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 (32) hide show
  1. package/README.md +41 -16
  2. package/index.js +20 -7
  3. package/package.json +4 -2
  4. package/schemas/film.schema.json +75 -4
  5. package/skills/video-studio/SKILL.md +95 -23
  6. package/skills/video-studio/assets/film.json +7 -14
  7. package/skills/video-studio/assets/scenes.py +115 -10
  8. package/skills/video-studio/assets/web/engine.js +18 -1
  9. package/skills/video-studio/assets/web/film.js +29 -4
  10. package/skills/video-studio/assets/web/index.html +5 -0
  11. package/skills/video-studio/references/audio-variation.md +158 -0
  12. package/skills/video-studio/references/audio.md +67 -5
  13. package/skills/video-studio/references/change-impact.md +1 -1
  14. package/skills/video-studio/references/quality-gates.md +1 -1
  15. package/skills/video-studio/references/web-engine.md +1 -1
  16. package/skills/video-studio/requirements.txt +2 -1
  17. package/skills/video-studio/scripts/filmcheck.py +81 -12
  18. package/skills/video-studio/scripts/filmkit.py +271 -18
  19. package/skills/video-studio/scripts/gates.py +75 -12
  20. package/skills/video-studio/scripts/qa_frames.py +109 -19
  21. package/skills/video-studio/scripts/render.py +420 -51
  22. package/skills/video-studio/scripts/scorekit.py +289 -46
  23. package/skills/video-studio/scripts/selfcheck.py +302 -2
  24. package/skills/video-studio/scripts/sfxkit.py +132 -30
  25. package/skills/video-studio/scripts/stagewatch.py +141 -31
  26. package/skills/video-studio/scripts/stylekit.py +94 -6
  27. package/skills/video-studio/scripts/voicekit.py +8 -0
  28. package/skills/video-studio/scripts/webengine.py +68 -14
  29. package/tools/audio_similarity.py +279 -0
  30. package/tools/ci_check.py +145 -34
  31. package/tools/estimate.py +133 -54
  32. package/tools/selftest_noscipy.py +70 -0
package/tools/estimate.py CHANGED
@@ -40,23 +40,46 @@ import unicodedata
40
40
  from pathlib import Path
41
41
 
42
42
  # ============================================================================
43
- # 基准常量(唯一出处:SKILL.md §12 本机实测;换机请重测)
43
+ # 基准常量(v0.7.2 重新校准:换机请重测后回填,别把这里的数字当通用真理)
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
+ PARALLEL_EFF = 0.5 # 分块并行的**实测效率**:2 进程只快 1.5×,不是 2×(见 CALIBRATION)
48
49
  WEB_SEC_PER_FRAME = (0.080, 0.130) # web 引擎:Edge 无头 PNG 截图 80–130 ms/帧
49
50
  WEB_STARTUP_SEC = 6.0 # 浏览器冷启动 + CDP 连接(12 s 片 28–30 s 含启动)
50
- X264_FPS = 141.0 # libx264 -preset medium -crf 17 ≈ 4.7× 实时
51
- TTS_SEC_PER_LINE = 4.0 # edge-tts:单句 4 s 语音约 4 s 合成(联网)
52
- SCORE_REFS = ((60.0, 2.0, 4.0), (389.0, 4.0, 8.0)) # (片长 s, 耗时低, 耗时高):两个实测点
51
+ X264_FPS = 141.0 # libx264 -preset medium -crf 17 ≈ 4.7× 实时(仅 reencode_video 用)
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
56
  SFX_SEC_PER_CUE = 0.20 # 单条短音效 合成+落位(24 配方单次 ≤1.5 s 是上限口径)
54
- SFX_MIN_SEC = 0.5
55
- LAYOUT_BASE, LAYOUT_PER_LINE = 0.4, 0.02 # 排轴 + srt/ass + 安全区/字形自查
56
- CUES_BASE, CUES_PER_LINE = 0.2, 0.02 # 提示点落盘 + 排序
57
+ SFX_MIN_SEC = 0.8 # 实测 18 条 cue 的模板片 sfx 墙钟 0.8 s(含 ffmpeg 写盘)
58
+ LAYOUT_BASE, LAYOUT_PER_LINE = 0.05, 0.01 # 排轴 + srt/ass + 安全区/字形自查(实测 29 s 片 <0.15 s)
59
+ CUES_BASE, CUES_PER_LINE = 0.05, 0.005 # 提示点落盘 + 排序(实测 29 s 片 <0.15 s)
57
60
  AUDIO_BASE, AUDIO_PER_SEC = 0.6, 0.012 # numpy 总线按采样点叠加 + 写 wav
58
- QA_SEC_PER_SEC = 1.0 / 30.0 # 30 s 片全量深检 ≈ 1 s(160×90 流式解码)
59
- QA_MIN_SEC = 0.8
61
+ # mux 默认是 -c:v copy + 两遍 loudnorm:实测 29 s 片 1.7–1.9 s,与帧数无关、跟片长弱相关。
62
+ MUX_BASE_SEC = 0.6 # 起 ffmpeg 两遍 + 封装
63
+ MUX_PER_SEC = 0.045 # 每片长秒(两遍 loudnorm 解码音频)
64
+ # QA:实测 29 s 片 6.2–6.8 s(含联系表 + 锐度参考帧重渲染 + 帧级深检)。原来的 1/30 把 qa 算了 6 倍小。
65
+ QA_BASE_SEC = (1.0, 3.0) # 固定开销:联系表 + 参考帧重渲染
66
+ QA_SEC_PER_SEC = (0.12, 0.25) # 随片长:帧级深检流式解码 + 响度曲线
67
+ UNMEASURED_BAND = 0.30 # 没有实测的阶段(layout/cues/sfx/audio)给 ±30% 的区间,别假装精确
68
+
69
+ # 校准出处:换成你自己的机器前,请按 SKILL.md §12 重测并回填——这些数字决定预演准不准。
70
+ CALIBRATION = dict(
71
+ machine=("AMD64 Family 26 Model 36 Stepping 0(24 逻辑核)/ Windows;"
72
+ "SKILL.md §12 记录的作者机是 Ryzen AI 9 HX 370 / 12C24T / Radeon 890M"
73
+ "(是否同一台未核实,只保证这些数字是在上述 24 逻辑核机器上测的)"),
74
+ measured_at="2026-11-10",
75
+ sdk="video-studio v0.7.1 scripts(ffmpeg 自带 x264)",
76
+ film="29.011 s / 870 帧 / 模板片(assets/film.json + assets/scenes.py)",
77
+ method="render.py --stage all --jobs 2,逐 stage 取 [time] 行墙钟;同片重复 2 次",
78
+ observed=dict(tts=2.2, layout=0.0, cues=0.0, score=6.3, sfx=0.8, audio=0.7,
79
+ frames=9.7, mux=1.9, qa=6.8, total=28.4),
80
+ extra="frames: 870 帧 jobs=1 14.37 s / jobs=2 9.53 s(并行效率 1.51×);"
81
+ " score 58 s 片 4.5–5.0 s; qa 6.2–6.8 s",
82
+ )
60
83
  CPS = 4.2 # 每秒约 4.2 字(voicekit 校准 3.84–4.61 cps 的中位口径)
61
84
  DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR = 0.62, 0.92, 3.0 # v0.5 body 基准
62
85
  CORES = 12 # HX 370:12C/24T;jobs > 12 不再线性加速
@@ -65,8 +88,8 @@ WAV_BYTES_PER_SEC = 48000 * 2 * 2 # 48 kHz 立体声 PCM16 = 192 KB/s
65
88
  VIDEO_MBPS = (1.2, 3.0) # crf17 @1080p30 图文内容(暗底大字 0.5–1 Mbps 属正常)
66
89
  AUDIO_MBPS = 0.192 # AAC 192 kbps
67
90
  QA_DISK_BYTES = 12 * 1024 * 1024 # 联系表 + 静帧 + deep.png
68
- BASELINE_NOTE = ("区间来自作者机器实测(SKILL.md §12:Ryzen AI 9 HX 370 / 12C24T / "
69
- "Radeon 890M 无 CUDA);换机请先重测再采信")
91
+ BASELINE_NOTE = ("区间来自 v0.7.2 本机实测(机型/样本见下方 calibration;"
92
+ "SKILL.md §12 记录的作者机是 Ryzen AI 9 HX 370 / 12C24T);换机请先重测再采信")
70
93
 
71
94
  STAGE_LABELS = {
72
95
  "tts": "旁白 edge-tts",
@@ -245,25 +268,53 @@ def score_seconds(total):
245
268
  return max(0.3, lo), max(0.5, hi)
246
269
 
247
270
 
248
- def frames_seconds(frames, jobs, engine, rerender):
249
- """frames 阶段墙钟(只算画面生成,编码单列在 mux 行)。
271
+ def _speedup(jobs):
272
+ """分块并行的**有效加速比**:不是 jobs 倍。
250
273
 
251
- PIL:帧数 ÷ 渲染 fps ÷ 并行度;web:启动开销 + 帧数 × 每帧截图耗时 ÷ 并行度。
252
- render.py 的分块 worker 是"边渲染边编码",所以真实成本 ≈ 本行 + mux 行。
274
+ 实测(870 帧模板片):jobs=1 → 14.37 s、jobs=2 → 9.53 s,只有 1.51×。
275
+ 原因很实在:父进程要 spawn 子进程、每段都要重新 import scenes.py / 解析主题、
276
+ 落盘 mux 串行、磁盘写带宽争用。按 jobs 线性折算会持续低估长片(越长的片分块越多)。
277
+ 这里用 1 + (jobs-1)*PARALLEL_EFF 的线性模型:比理论值保守,实测 2 进程刚好对上。
253
278
  """
254
279
  jobs_eff = max(1, min(int(jobs), CORES))
280
+ return 1.0 + (jobs_eff - 1) * PARALLEL_EFF
281
+
282
+
283
+ def frames_seconds(frames, jobs, engine, rerender):
284
+ """frames 阶段墙钟(只算画面生成;不重编码时封装成本在 mux 行)。
285
+
286
+ PIL:帧数 ÷ 渲染 fps ÷ 有效加速比;web:启动开销 + 帧数 × 每帧截图耗时 ÷ 加速比。
287
+ render.py 的分块 worker 是边渲染边编码,所以这里的 fps 已经含了编码开销。
288
+ """
289
+ sp = _speedup(jobs)
255
290
  if engine == "web":
256
- base = [WEB_STARTUP_SEC + frames * spf / jobs_eff for spf in WEB_SEC_PER_FRAME]
291
+ base = [WEB_STARTUP_SEC + frames * spf / sp for spf in WEB_SEC_PER_FRAME]
257
292
  else:
258
- base = [frames / rfps / jobs_eff for rfps in PIL_FPS]
293
+ base = [frames / rfps / sp for rfps in PIL_FPS]
259
294
  base = sorted(base)
260
295
  return base[0] * rerender, base[-1] * rerender
261
296
 
262
297
 
263
- def encode_seconds(frames, jobs):
264
- """x264 编码墙钟:141 fps(medium/crf17),分块并行时按 --jobs 摊到各进程。"""
265
- jobs_eff = max(1, min(int(jobs), CORES))
266
- return frames / X264_FPS / jobs_eff
298
+ def mux_seconds_pair(frames, jobs, total, script):
299
+ """mux 阶段区间:默认 -c:v copy 只跟片长有关;reencode_video=true 才跟帧数有关。"""
300
+ reencode = bool(script.get("reencode_video", False))
301
+ if reencode:
302
+ v = frames / X264_FPS / _speedup(jobs)
303
+ return v * 0.8, v * 1.6
304
+ base = MUX_BASE_SEC + MUX_PER_SEC * max(0.0, float(total))
305
+ return base * 0.75, base * 1.35
306
+
307
+
308
+ def mux_seconds(frames, jobs, total, reencode=False):
309
+ """mux 阶段墙钟。
310
+
311
+ 默认 `-c:v copy` + 两遍 loudnorm:视频流不解码不重编码,成本 ≈ 起 ffmpeg + 按片长
312
+ 解码音频跑两遍。实测 29 s 片 1.7–1.9 s。
313
+ reencode_video=true 时才要重编视频,那时才用 x264 速率并按有效加速比摊。
314
+ """
315
+ if reencode:
316
+ return frames / X264_FPS / _speedup(jobs)
317
+ return MUX_BASE_SEC + MUX_PER_SEC * max(0.0, float(total))
267
318
 
268
319
 
269
320
  def chunk_plan(frames, fps, jobs, explicit):
@@ -390,49 +441,63 @@ def build_report(script_path, script, args):
390
441
  preset = script.get("preset", "medium")
391
442
 
392
443
  # ---- 各阶段 ----
444
+ # 没有实测的阶段一律给区间(正负 UNMEASURED_BAND),别把经验公式输出当精确值。
445
+ def _band(v):
446
+ # 下界永远让它摸到 0:这些阶段实测常常 <0.1 s(render.py 的 [time] 行按 0.1 s 打印,
447
+ # 于是显示 0.0),下界写成正值会让"实测值落在区间外"这种假告警出现。
448
+ return min(0.0, v * (1.0 - UNMEASURED_BAND)), v * (1.0 + UNMEASURED_BAND)
393
449
  workers = int(_num(script.get("tts_workers", 4), 4) or 4)
394
450
  workers = max(1, min(workers, max(1, n)))
395
- tts_lo = tts_hi = n * TTS_SEC_PER_LINE / float(workers)
396
- layout = LAYOUT_BASE + LAYOUT_PER_LINE * n
397
- cues = CUES_BASE + CUES_PER_LINE * n
451
+ # TTS 按每句每 worker 摊:实测 4 句 / 4 workers = 2.2 s(串行口径 8.8 s)。
452
+ tts_lo = n * TTS_SEC_PER_LINE[0] / float(workers)
453
+ tts_hi = n * TTS_SEC_PER_LINE[1] / float(workers)
454
+ layout_lo, layout_hi = _band(LAYOUT_BASE + LAYOUT_PER_LINE * n)
455
+ cues_lo, cues_hi = _band(CUES_BASE + CUES_PER_LINE * n)
398
456
  if score_on:
399
457
  score_lo, score_hi = score_seconds(total)
400
458
  else:
401
459
  score_lo = score_hi = 0.0
402
- sfx = max(SFX_MIN_SEC, n_cues * SFX_SEC_PER_CUE) if sfx_on else 0.0
403
- audio = AUDIO_BASE + total * AUDIO_PER_SEC
460
+ sfx_lo, sfx_hi = _band(max(SFX_MIN_SEC, n_cues * SFX_SEC_PER_CUE)) if sfx_on else (0.0, 0.0)
461
+ audio_lo, audio_hi = _band(AUDIO_BASE + total * AUDIO_PER_SEC)
404
462
  fr_lo, fr_hi = frames_seconds(frames, jobs_eff, engine, rerender)
405
- mux = encode_seconds(frames, jobs_eff)
406
- qa = max(QA_MIN_SEC, total * QA_SEC_PER_SEC)
463
+ mux_lo, mux_hi = mux_seconds_pair(frames, jobs_eff, total, script)
464
+ qa_lo = max(0.5, QA_BASE_SEC[0] + total * QA_SEC_PER_SEC[0])
465
+ qa_hi = max(qa_lo, QA_BASE_SEC[1] + total * QA_SEC_PER_SEC[1])
407
466
 
408
467
  stages = [
409
468
  dict(stage="tts", lo=tts_lo, hi=tts_hi, measured=True,
410
- note=("%d 句 × %.1f s ÷ tts_workers=%d(串行口径 %.1f s;需联网,edge-tts 免费端点)"
411
- % (n, TTS_SEC_PER_LINE, workers, n * TTS_SEC_PER_LINE))),
412
- dict(stage="layout", lo=layout, hi=layout, measured=False,
413
- note="排轴 + sub.srt/ass + 安全区/字形自查(经验值,未单测)"),
414
- dict(stage="cues", lo=cues, hi=cues, measured=False,
415
- note="%d 条 sfx.cues 落位 + 排序(经验值,未单测)" % n_cues),
469
+ note=("%d 句 ÷ tts_workers=%d,每句每 worker %.1f–%.1f s(实测 2.2 s/4 句;"
470
+ "需联网,edge-tts 免费端点波动大)"
471
+ % (n, workers, TTS_SEC_PER_LINE[0], TTS_SEC_PER_LINE[1]))),
472
+ dict(stage="layout", lo=layout_lo, hi=layout_hi, measured=False,
473
+ note="排轴 + sub.srt/ass + 安全区/字形自查(经验模型,给 ±%d%% 区间)"
474
+ % int(UNMEASURED_BAND * 100)),
475
+ dict(stage="cues", lo=cues_lo, hi=cues_hi, measured=False,
476
+ note="%d 条 sfx.cues 落位 + 排序(经验模型,给 ±%d%% 区间)"
477
+ % (n_cues, int(UNMEASURED_BAND * 100))),
416
478
  dict(stage="score", lo=score_lo, hi=score_hi, measured=bool(score_on),
417
- note=("按 60 s≈2–4 s / 389 s≈4–8 s 线性折算" if score_on
418
- else ("mode=%s:不跑 scorekit(%s)"
419
- % (mode or "-", "外部配乐/和弦床/关掉配乐")))),
420
- dict(stage="sfx", lo=sfx, hi=sfx, measured=False,
421
- note=("%d 条音效 ≈ %.1f s(上限口径:单条 ≤1.5 s;长环境声 wind/rain 另计)"
422
- % (n_cues, sfx) if sfx_on else "没有 sfx.cues:跳过(whoosh 兼容路径除外)")),
423
- dict(stage="audio", lo=audio, hi=audio, measured=False,
424
- note="旁白+配乐+音效三总线混音 + 写 audio.wav(经验值:%.2f s + %.3f×片长)"
479
+ note=("以实测点 29 s≈3.6–6.3 s / 58 s≈4.5–5.0 s 为锚(--stage all 里与其它阶段争 CPU 会更慢;389 s 只是外推锚点)"
480
+ if score_on else
481
+ ("mode=%s:不跑 scorekit(%s)"
482
+ % (mode or "-", "外部配乐/和弦床/关掉配乐")))),
483
+ dict(stage="sfx", lo=sfx_lo, hi=sfx_hi, measured=False,
484
+ note=("%d 条音效(实测 18 条 0.8 s;单条上限口径 ≤1.5 s,长环境声 wind/rain 另计)"
485
+ % n_cues if sfx_on else
486
+ "没有 sfx.cues:跳过(whoosh 兼容路径除外)")),
487
+ dict(stage="audio", lo=audio_lo, hi=audio_hi, measured=False,
488
+ note="三总线混音 + 写 audio.wav(实测 29 s 片 0.7 s;模型 %.2f + %.3f×片长)"
425
489
  % (AUDIO_BASE, AUDIO_PER_SEC)),
426
490
  dict(stage="frames", lo=fr_lo, hi=fr_hi, measured=True,
427
- note=("%s:%s,--jobs %d(本机 12 核)× 返工系数 %.2g;编码成本单列在 mux 行"
491
+ note=("%s:%s,--jobs %d → 有效加速比 %.2f×(实测 2 进程 1.51×),"
492
+ "返工系数 %.2g;不重编码时封装成本在 mux 行"
428
493
  % (engine, "PIL 40–80 fps" if engine == "pil"
429
- else "web 80–130 ms/帧 + 启动 6 s", jobs_eff, rerender))),
430
- dict(stage="mux", lo=mux, hi=mux, measured=True,
431
- note=("x264 medium crf17 ≈%d fps,按 --jobs %d 摊(分块 worker 边渲染边编码);"
432
- "render.py 的 mux 本身是 -c:v copy + 两遍 loudnorm,通常 <1 s"
433
- % (int(X264_FPS), jobs_eff))),
434
- dict(stage="qa", lo=qa, hi=qa, measured=True,
435
- note="30 s 片全量深检 ≈1 s,按片长线性折算 + 联系表/静帧"),
494
+ else "web 80–130 ms/帧 + 启动 6 s", jobs_eff,
495
+ _speedup(jobs_eff), rerender))),
496
+ dict(stage="mux", lo=mux_lo, hi=mux_hi, measured=True,
497
+ note=("-c:v copy + 两遍 loudnorm(实测 29 s 片 1.7–1.9 s,与帧数无关);"
498
+ "reencode_video=true 才走 x264 %.0f fps" % X264_FPS)),
499
+ dict(stage="qa", lo=qa_lo, hi=qa_hi, measured=True,
500
+ note=("联系表 + 锐度参考帧重渲染 + 帧级深检(实测 29 s 片 6.2–6.8 s,不是 1 s)")),
436
501
  ]
437
502
  total_lo = sum(s["lo"] for s in stages)
438
503
  total_hi = sum(s["hi"] for s in stages)
@@ -485,11 +550,16 @@ def build_report(script_path, script, args):
485
550
  "分辨率 1920x1080@%d 由 filmkit.W/H/FPS 写死,film.json 改不了" % FPS,
486
551
  "lead=%.2f / tail=%.2f / min_dur=%.1f(v0.5 body 基准;剧本写了就以剧本为准)"
487
552
  % (DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR),
488
- "tts 耗时按 %d 句 × %.1f s ÷ workers;实际受网络波动影响" % (n, TTS_SEC_PER_LINE),
489
- "frames 的 jobs 并行按线性折算且封顶 %d 核(超过不再线性)" % CORES,
490
- "layout/cues/audio/sfx 的耗时为经验模型(无单阶段实测),量级可信、小数位不可信",
553
+ "tts 耗时按 %d 句 ÷ workers × %.1f–%.1f s(每句每 worker);实际受网络波动影响"
554
+ % (n, TTS_SEC_PER_LINE[0], TTS_SEC_PER_LINE[1]),
555
+ "frames 的并行不是 jobs 倍:有效加速比 = 1 + (jobs-1)×%.2f,封顶 %d 核"
556
+ % (PARALLEL_EFF, CORES),
557
+ "mux 默认 -c:v copy,只跟片长有关;reencode_video=true 才按帧数重编",
558
+ "layout/cues/audio/sfx 给 ±%d%% 经验区间(无单阶段实测)" % int(UNMEASURED_BAND * 100),
559
+ "基准来源见 calibration 字段(机型/日期/样本);换机请重测后回填顶部常量",
491
560
  ],
492
561
  "baseline": BASELINE_NOTE,
562
+ "calibration": dict(CALIBRATION),
493
563
  }
494
564
  return report
495
565
 
@@ -545,6 +615,15 @@ def print_report(rep):
545
615
  % (bn["stage"], bn["seconds_hi"], bn["share"] * 100, bn["advice"]))
546
616
  print()
547
617
  print(" 基准说明:%s" % rep["baseline"])
618
+ cal = rep.get("calibration") or {}
619
+ if cal:
620
+ print(" 校准出处(换机请重测后回填 tools/estimate.py 顶部常量):")
621
+ print(" 机型 %s" % cal.get("machine", "-"))
622
+ print(" 实测 %s · 样本 %s" % (cal.get("measured_at", "-"), cal.get("film", "-")))
623
+ print(" 方法 %s" % cal.get("method", "-"))
624
+ obs = cal.get("observed") or {}
625
+ if obs:
626
+ print(" 实测值 " + " · ".join("%s %.1fs" % (k, v) for k, v in obs.items()))
548
627
  print(" 假设:")
549
628
  for a in rep["assumptions"]:
550
629
  print(" - %s" % a)
@@ -0,0 +1,70 @@
1
+ # -*- coding: utf-8 -*-
2
+ r"""selftest_noscipy.py — 在"没有 scipy"的环境里跑可选模块的自检。
3
+
4
+ 为什么需要它:scipy 在 requirements.txt 里是**可选**依赖,没装 scipy 的用户会走
5
+ "分块 FFT 卷积"兜底路径——那是他们真正运行的代码,必须在 CI 里被测到。
6
+ 本地开发机装了 scipy,所以要用这个脚本把 scipy 屏蔽掉再跑。
7
+
8
+ 为什么用子进程:每个模块都在干净解释器里跑,避免"上一个模块已经把 scipy 用上了"
9
+ 这种互相污染;同时能保证 sys.modules['scipy'] = None 在 import 之前就生效。
10
+
11
+ 用法:
12
+ python tools/selftest_noscipy.py # 默认 sfxkit scorekit
13
+ python tools/selftest_noscipy.py sfxkit # 只跑指定模块
14
+
15
+ 退出码:任一模块自检失败即返回 1。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ SCRIPTS = ROOT / "skills" / "video-studio" / "scripts"
26
+
27
+ # 子进程里先屏蔽 scipy,再 import 目标模块并跑它的 selftest()。
28
+ RUNNER = r"""
29
+ import sys
30
+ sys.modules["scipy"] = None # 模拟"没装 scipy":import scipy 抛 ImportError
31
+ sys.path.insert(0, sys.argv[1])
32
+ name = sys.argv[2]
33
+ mod = __import__(name)
34
+
35
+ # 自证屏蔽真的生效:sfxkit 模块级 _sig 必须是 None(它是全仓库唯一的 scipy 入口)。
36
+ sfx = getattr(mod, "S", None) if name != "sfxkit" else mod
37
+ sig = getattr(sfx, "_sig", None) if sfx is not None else None
38
+ if name in ("sfxkit", "scorekit") and sig is not None:
39
+ print("屏蔽失败:%s 仍然拿到了 scipy" % name, file=sys.stderr)
40
+ raise SystemExit(2)
41
+
42
+ raise SystemExit(mod.selftest())
43
+ """
44
+
45
+
46
+ def main(argv=None) -> int:
47
+ args = list(argv if argv is not None else sys.argv[1:])
48
+ modules = args or ["sfxkit", "scorekit"]
49
+ bad = []
50
+ for name in modules:
51
+ print("=" * 70)
52
+ print("[无 scipy 兜底路径] %s --selftest" % name)
53
+ print("=" * 70, flush=True)
54
+ proc = subprocess.run(
55
+ [sys.executable, "-c", RUNNER, str(SCRIPTS), name],
56
+ cwd=str(SCRIPTS),
57
+ env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
58
+ )
59
+ if proc.returncode != 0:
60
+ bad.append("%s (exit %d)" % (name, proc.returncode))
61
+ print()
62
+ if bad:
63
+ print("无 scipy 兜底路径自检失败:%s" % ", ".join(bad))
64
+ return 1
65
+ print("无 scipy 兜底路径自检全部通过:%s" % ", ".join(modules))
66
+ return 0
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())