@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
package/tools/estimate.py CHANGED
@@ -40,23 +40,54 @@ 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
+ # v0.8 G11(全片 VMAF/SSIM):参考帧由场景函数**重渲一遍**再喂 libvmaf,成本跟帧数走、
69
+ # 与 frames 阶段同量级。实测口径差异极大:qavmaf 作者 29 s 片 12.0 s(≈0.40× 片长);
70
+ # SKILL.md §12 同一部模板片 14.2 s;同机另一轮负载下 28.8 s。这里取 0.40–1.00× 片长
71
+ # (下限=干净机、上限=争 CPU 的长片外推),固定开销单独列 1–2 s。
72
+ # 注:v0.7.2 那套 1.0–1.6× 的系数没有 v0.8 实测支撑,把 29 s 片算成 31–50 s(实际 12–29 s),
73
+ # 预演总量因此系统性偏大近 2 倍——这类"拍脑袋的悲观系数"比低估更糟:排期会按假的数字走。
74
+ VMAF_BASE_SEC = (1.0, 2.0) # 起渲染进程 + 参考帧管线搭建
75
+ VMAF_SEC_PER_SEC = (0.40, 1.00) # 每片长秒(参考帧重渲 + libvmaf 逐帧比)
76
+
77
+ # 校准出处:换成你自己的机器前,请按 SKILL.md §12 重测并回填——这些数字决定预演准不准。
78
+ CALIBRATION = dict(
79
+ machine=("AMD64 Family 26 Model 36 Stepping 0(24 逻辑核)/ Windows;"
80
+ "SKILL.md §12 记录的作者机是 Ryzen AI 9 HX 370 / 12C24T / Radeon 890M"
81
+ "(是否同一台未核实,只保证这些数字是在上述 24 逻辑核机器上测的)"),
82
+ 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)",
85
+ method="render.py --stage all --jobs 2,逐 stage 取 [time] 行墙钟;同片重复 2 次",
86
+ observed=dict(tts=2.2, layout=0.0, cues=0.0, score=6.3, sfx=0.8, audio=0.7,
87
+ frames=9.7, mux=1.9, qa=6.8, total=28.4),
88
+ extra="frames: 870 帧 jobs=1 14.37 s / jobs=2 9.53 s(并行效率 1.51×);"
89
+ " score 58 s 片 4.5–5.0 s; qa 6.2–6.8 s",
90
+ )
60
91
  CPS = 4.2 # 每秒约 4.2 字(voicekit 校准 3.84–4.61 cps 的中位口径)
61
92
  DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR = 0.62, 0.92, 3.0 # v0.5 body 基准
62
93
  CORES = 12 # HX 370:12C/24T;jobs > 12 不再线性加速
@@ -65,8 +96,8 @@ WAV_BYTES_PER_SEC = 48000 * 2 * 2 # 48 kHz 立体声 PCM16 = 192 KB/s
65
96
  VIDEO_MBPS = (1.2, 3.0) # crf17 @1080p30 图文内容(暗底大字 0.5–1 Mbps 属正常)
66
97
  AUDIO_MBPS = 0.192 # AAC 192 kbps
67
98
  QA_DISK_BYTES = 12 * 1024 * 1024 # 联系表 + 静帧 + deep.png
68
- BASELINE_NOTE = ("区间来自作者机器实测(SKILL.md §12:Ryzen AI 9 HX 370 / 12C24T / "
69
- "Radeon 890M 无 CUDA);换机请先重测再采信")
99
+ BASELINE_NOTE = ("区间来自 v0.7.2 本机实测(机型/样本见下方 calibration;"
100
+ "SKILL.md §12 记录的作者机是 Ryzen AI 9 HX 370 / 12C24T);换机请先重测再采信")
70
101
 
71
102
  STAGE_LABELS = {
72
103
  "tts": "旁白 edge-tts",
@@ -78,6 +109,10 @@ STAGE_LABELS = {
78
109
  "frames": "逐帧渲染+编码",
79
110
  "mux": "合成成片",
80
111
  "qa": "质检+抽帧",
112
+ # v0.8:交付前的三道新门禁单独列(它们的成本不能藏进 qa 里)
113
+ "preflight": "渲染前预检",
114
+ "vmaf": "全片VMAF/SSIM",
115
+ "asr": "ASR回读",
81
116
  }
82
117
 
83
118
 
@@ -186,6 +221,31 @@ def _fmt_span(lo, hi):
186
221
  return "%.1f–%.1f s" % (float(lo), float(hi))
187
222
 
188
223
 
224
+ def _qavmaf_sample_step(vcfg=None):
225
+ """qavmaf 的抽样步长(sample 模式每 N 帧取 1 帧)。
226
+
227
+ 优先级与 render.py 的 _qa_vmaf_gate 一致:先读 film.json 的 qa.vmaf.sample_step,
228
+ 没写才用 qavmaf.SAMPLE_STEP。预演不另抄一份常量,否则改一处漏一处;而"用户显式
229
+ 写了 sample_step=20 却按默认 5 预演"会把这道门禁的成本高估 4 倍。
230
+ 读不到 qavmaf 时按 1(=full)保守估——那种环境下 render.py 的 G11 本来也会记
231
+ skipped(没有模块),多估不会误导排期,少估才会。
232
+ """
233
+ try:
234
+ if isinstance(vcfg, dict) and vcfg.get("sample_step") is not None:
235
+ return max(1, int(vcfg["sample_step"]))
236
+ except (TypeError, ValueError):
237
+ pass
238
+ try:
239
+ scripts = str(Path(__file__).resolve().parent.parent /
240
+ "skills" / "video-studio" / "scripts")
241
+ if scripts not in sys.path:
242
+ sys.path.insert(0, scripts)
243
+ import qavmaf
244
+ return max(1, int(qavmaf.SAMPLE_STEP))
245
+ except Exception: # noqa: BLE001
246
+ return 1
247
+
248
+
189
249
  # ============================================================================
190
250
  # 估算核心
191
251
  # ============================================================================
@@ -245,25 +305,41 @@ def score_seconds(total):
245
305
  return max(0.3, lo), max(0.5, hi)
246
306
 
247
307
 
248
- def frames_seconds(frames, jobs, engine, rerender):
249
- """frames 阶段墙钟(只算画面生成,编码单列在 mux 行)。
308
+ def _speedup(jobs):
309
+ """分块并行的**有效加速比**:不是 jobs 倍。
250
310
 
251
- PIL:帧数 ÷ 渲染 fps ÷ 并行度;web:启动开销 + 帧数 × 每帧截图耗时 ÷ 并行度。
252
- render.py 的分块 worker 是"边渲染边编码",所以真实成本 ≈ 本行 + mux 行。
311
+ 实测(870 帧模板片):jobs=1 → 14.37 s、jobs=2 → 9.53 s,只有 1.51×。
312
+ 原因很实在:父进程要 spawn 子进程、每段都要重新 import scenes.py / 解析主题、
313
+ 落盘 mux 串行、磁盘写带宽争用。按 jobs 线性折算会持续低估长片(越长的片分块越多)。
314
+ 这里用 1 + (jobs-1)*PARALLEL_EFF 的线性模型:比理论值保守,实测 2 进程刚好对上。
253
315
  """
254
316
  jobs_eff = max(1, min(int(jobs), CORES))
317
+ return 1.0 + (jobs_eff - 1) * PARALLEL_EFF
318
+
319
+
320
+ def frames_seconds(frames, jobs, engine, rerender):
321
+ """frames 阶段墙钟(只算画面生成;不重编码时封装成本在 mux 行)。
322
+
323
+ PIL:帧数 ÷ 渲染 fps ÷ 有效加速比;web:启动开销 + 帧数 × 每帧截图耗时 ÷ 加速比。
324
+ render.py 的分块 worker 是边渲染边编码,所以这里的 fps 已经含了编码开销。
325
+ """
326
+ sp = _speedup(jobs)
255
327
  if engine == "web":
256
- base = [WEB_STARTUP_SEC + frames * spf / jobs_eff for spf in WEB_SEC_PER_FRAME]
328
+ base = [WEB_STARTUP_SEC + frames * spf / sp for spf in WEB_SEC_PER_FRAME]
257
329
  else:
258
- base = [frames / rfps / jobs_eff for rfps in PIL_FPS]
330
+ base = [frames / rfps / sp for rfps in PIL_FPS]
259
331
  base = sorted(base)
260
332
  return base[0] * rerender, base[-1] * rerender
261
333
 
262
334
 
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
335
+ def mux_seconds_pair(frames, jobs, total, script):
336
+ """mux 阶段区间:默认 -c:v copy 只跟片长有关;reencode_video=true 才跟帧数有关。"""
337
+ reencode = bool(script.get("reencode_video", False))
338
+ if reencode:
339
+ v = frames / X264_FPS / _speedup(jobs)
340
+ return v * 0.8, v * 1.6
341
+ base = MUX_BASE_SEC + MUX_PER_SEC * max(0.0, float(total))
342
+ return base * 0.75, base * 1.35
267
343
 
268
344
 
269
345
  def chunk_plan(frames, fps, jobs, explicit):
@@ -292,7 +368,6 @@ def disk_estimate(frames, total, n_lines, n_cues, crf, music_on):
292
368
  factor = 2.0 ** ((17.0 - crf) / 6.0) # crf 每降 6 体积约翻倍(x264 经验值)
293
369
  vlo = max(0.3, VIDEO_MBPS[0] * factor)
294
370
  vhi = max(0.3, VIDEO_MBPS[1] * factor)
295
- vid_lo = total * vlo * 1e6 / 8.0
296
371
  vid_hi = total * vhi * 1e6 / 8.0
297
372
  out_lo = total * (vlo + AUDIO_MBPS) * 1e6 / 8.0
298
373
  out_hi = total * (vhi + AUDIO_MBPS) * 1e6 / 8.0
@@ -345,16 +420,39 @@ def build_report(script_path, script, args):
345
420
  _warn("engine=%r 不认识(只能是 pil / web),按 pil 预演" % engine)
346
421
  engine = "pil"
347
422
 
423
+ # v0.8:画幅/帧率可以按片配置(只允许 16:9 横屏 + 下采样输出,见 filmkit.video_plan)。
424
+ # 预演必须跟着走,否则 720p 片会被按 1080p 估帧数与磁盘。
425
+ try:
426
+ import sys as _sys
427
+ _sys.path.insert(0, str(Path(__file__).resolve().parent.parent /
428
+ "skills" / "video-studio" / "scripts"))
429
+ import filmkit as _FK
430
+ _vp = _FK.video_plan(script)
431
+ except ImportError as e:
432
+ # 只把"缺 filmkit"当降级:那是能用模板常量继续估的环境问题。
433
+ _warn("装载不了 filmkit(%s),画幅/帧率按 %dx%d@%d 估" % (e, W, H, FPS))
434
+ _vp = dict(fps=FPS, w=W, h=H, out_w=W, out_h=H,
435
+ label="%dx%d" % (W, H))
436
+ except Exception as e: # noqa: BLE001
437
+ # video 块写错(非 16:9 / 非法 fps / 上采样)时 render.py 会在 st_layout 直接报错,
438
+ # 预演却静默按默认值算并打印 ok:true——那等于告诉用户"能渲",必须显式说清楚。
439
+ _warn("film.json 的 video 块不合法(%s: %s):render.py 会拒绝这部片子,"
440
+ "下面的画幅/帧率只是占位估算" % (type(e).__name__, e))
441
+ _vp = dict(fps=FPS, w=W, h=H, out_w=W, out_h=H,
442
+ label="%dx%d" % (W, H))
348
443
  tl, tl_path = read_timeline(args.work)
349
444
  if tl is not None:
350
445
  total = _num(tl.get("total"))
351
- fps = int(_num(tl.get("fps"), FPS)) or FPS
446
+ fps = int(_num(tl.get("fps"), _vp["fps"])) or _vp["fps"]
352
447
  frames = int(_num(tl.get("frames"), round(total * fps)))
353
448
  source = "timeline"
354
449
  source_note = "真实帧数:%s" % tl_path
355
450
  else:
356
451
  total, _scenes = estimate_timeline(script)
357
- fps = FPS
452
+ # v0.8:没有 timeline.json 时也必须用 film.json 的 video.fps —— 定死 30 会让
453
+ # 24/25/50/60 fps 的片子帧数、磁盘、分块全部按 30 fps 算(24 fps 高估 25%,
454
+ # 60 fps 低估一半),而 render.py 的 build_timeline(fps=vp["fps"]) 用的是真实值。
455
+ fps = int(_vp["fps"])
358
456
  frames = int(round(total * fps))
359
457
  source = "script_estimate"
360
458
  source_note = ("粗估(等效字数 ÷ %.1f 字/秒 + lead/tail + hold;没有 %s)"
@@ -390,49 +488,115 @@ def build_report(script_path, script, args):
390
488
  preset = script.get("preset", "medium")
391
489
 
392
490
  # ---- 各阶段 ----
491
+ # 没有实测的阶段一律给区间(正负 UNMEASURED_BAND),别把经验公式输出当精确值。
492
+ def _band(v):
493
+ # 下界永远让它摸到 0:这些阶段实测常常 <0.1 s(render.py 的 [time] 行按 0.1 s 打印,
494
+ # 于是显示 0.0),下界写成正值会让"实测值落在区间外"这种假告警出现。
495
+ return min(0.0, v * (1.0 - UNMEASURED_BAND)), v * (1.0 + UNMEASURED_BAND)
393
496
  workers = int(_num(script.get("tts_workers", 4), 4) or 4)
394
497
  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
498
+ # TTS 按每句每 worker 摊:实测 4 句 / 4 workers = 2.2 s(串行口径 8.8 s)。
499
+ tts_lo = n * TTS_SEC_PER_LINE[0] / float(workers)
500
+ tts_hi = n * TTS_SEC_PER_LINE[1] / float(workers)
501
+ layout_lo, layout_hi = _band(LAYOUT_BASE + LAYOUT_PER_LINE * n)
502
+ cues_lo, cues_hi = _band(CUES_BASE + CUES_PER_LINE * n)
398
503
  if score_on:
399
504
  score_lo, score_hi = score_seconds(total)
400
505
  else:
401
506
  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
507
+ sfx_lo, sfx_hi = _band(max(SFX_MIN_SEC, n_cues * SFX_SEC_PER_CUE)) if sfx_on else (0.0, 0.0)
508
+ audio_lo, audio_hi = _band(AUDIO_BASE + total * AUDIO_PER_SEC)
404
509
  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)
510
+ mux_lo, mux_hi = mux_seconds_pair(frames, jobs_eff, total, script)
511
+ qa_lo = max(0.5, QA_BASE_SEC[0] + total * QA_SEC_PER_SEC[0])
512
+ 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× 片长,
515
+ # 参考帧重渲与 libvmaf 都在里面,见 VMAF_* 常量注释);ASR small ≈ 6–10 s。
516
+ # 这三项全在**交付前**跑,不写进预演就等于"预演说 40 s、实际 90 s"——所以单独列 stage。
517
+ qcfg = script.get("qa") or {}
518
+ if not isinstance(qcfg, dict):
519
+ qcfg = {}
520
+ # render.py 的 _qa_vmaf_gate / _qa_asr_gate 都用 isinstance 兜住了非 dict 的配置块;
521
+ # 预演以前直接 .get(),于是 "qa": {"vmaf": "off"} 这种(用户以为写了个开关)
522
+ # 会在 --json 下甩 AttributeError traceback、退出码 1——比 render 还不宽容。
523
+ vcfg = qcfg.get("vmaf")
524
+ if not isinstance(vcfg, dict):
525
+ if vcfg is not None:
526
+ _warn("qa.vmaf 不是对象(收到 %r):按默认 full 预演(render.py 会忽略这个值)"
527
+ % (vcfg,))
528
+ vcfg = {}
529
+ vmaf_mode = str(vcfg.get("mode") or "full").lower()
530
+ vmaf_on = vmaf_mode != "off" and engine == "pil" # web 路线没有可重渲参考帧
531
+ # sample 模式(qavmaf 每 SAMPLE_STEP 帧取 1 帧)只渲/比 1/step 的帧,固定开销不变。
532
+ # 预演以前不看 mode,于是 --vmaf sample 的真实成本被按全片算:29 s 片实测 sample
533
+ # 2.95 s vs full 12.0 s,而预演两边都给同一个数(片长越长错得越多)。
534
+ vmaf_step = 1
535
+ if vmaf_mode == "sample":
536
+ vmaf_step = _qavmaf_sample_step(vcfg)
537
+ # sample 只压缩"逐帧"那一半,固定开销照付。
538
+ vmaf_lo = (VMAF_BASE_SEC[0] + total * VMAF_SEC_PER_SEC[0] / vmaf_step) if vmaf_on else 0.0
539
+ vmaf_hi = (VMAF_BASE_SEC[1] + total * VMAF_SEC_PER_SEC[1] / vmaf_step) if vmaf_on else 0.0
540
+ acfg = qcfg.get("asr")
541
+ if not isinstance(acfg, dict):
542
+ if acfg is not None:
543
+ _warn("qa.asr 不是对象(收到 %r):按默认(开)预演" % (acfg,))
544
+ acfg = {}
545
+ asr_on = bool(acfg.get("enabled", True)) != False # noqa: E712 默认开
546
+ asr_lo = (total * 0.18 + 3.5) if asr_on else 0.0
547
+ asr_hi = (total * 0.35 + 6.5) if asr_on else 0.0
548
+ pre_lo, pre_hi = (0.3, 0.8)
549
+ if not vmaf_on:
550
+ vmaf_note = "本次不跑(--vmaf off 或 engine=web)"
551
+ elif vmaf_mode == "sample":
552
+ vmaf_note = ("sample 模式每 %d 帧取 1 帧:逐帧成本只算 1/%d,参考帧管线固定开销照付。"
553
+ "同机负载不同实测能差近 2×(SKILL.md §12:14.2 s / 另一轮 28.8 s)"
554
+ % (vmaf_step, vmaf_step))
555
+ else:
556
+ vmaf_note = ("全片逐帧比较(参考帧=场景函数重渲染,不是成片抽帧)。同机负载不同"
557
+ "实测能差近 2×(SKILL.md §12:14.2 s / 另一轮 28.8 s)")
407
558
 
408
559
  stages = [
409
560
  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),
561
+ note=("%d 句 ÷ tts_workers=%d,每句每 worker %.1f–%.1f s(实测 2.2 s/4 句;"
562
+ "需联网,edge-tts 免费端点波动大)"
563
+ % (n, workers, TTS_SEC_PER_LINE[0], TTS_SEC_PER_LINE[1]))),
564
+ dict(stage="layout", lo=layout_lo, hi=layout_hi, measured=False,
565
+ note="排轴 + sub.srt/ass + 安全区/字形自查(经验模型,给 ±%d%% 区间)"
566
+ % int(UNMEASURED_BAND * 100)),
567
+ dict(stage="cues", lo=cues_lo, hi=cues_hi, measured=False,
568
+ note="%d 条 sfx.cues 落位 + 排序(经验模型,给 ±%d%% 区间)"
569
+ % (n_cues, int(UNMEASURED_BAND * 100))),
416
570
  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×片长)"
571
+ note=("以实测点 29 s≈3.6–6.3 s / 58 s≈4.5–5.0 s 为锚(--stage all 里与其它阶段争 CPU 会更慢;389 s 只是外推锚点)"
572
+ if score_on else
573
+ ("mode=%s:不跑 scorekit(%s)"
574
+ % (mode or "-", "外部配乐/和弦床/关掉配乐")))),
575
+ dict(stage="sfx", lo=sfx_lo, hi=sfx_hi, measured=False,
576
+ note=("%d 条音效(实测 18 条 0.8 s;单条上限口径 ≤1.5 s,长环境声 wind/rain 另计)"
577
+ % n_cues if sfx_on else
578
+ "没有 sfx.cues:跳过(whoosh 兼容路径除外)")),
579
+ dict(stage="audio", lo=audio_lo, hi=audio_hi, measured=False,
580
+ note="三总线混音 + 写 audio.wav(实测 29 s 片 0.7 s;模型 %.2f + %.3f×片长)"
425
581
  % (AUDIO_BASE, AUDIO_PER_SEC)),
426
582
  dict(stage="frames", lo=fr_lo, hi=fr_hi, measured=True,
427
- note=("%s:%s,--jobs %d(本机 12 核)× 返工系数 %.2g;编码成本单列在 mux 行"
583
+ note=("%s:%s,--jobs %d → 有效加速比 %.2f×(实测 2 进程 1.51×),"
584
+ "返工系数 %.2g;不重编码时封装成本在 mux 行"
428
585
  % (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,按片长线性折算 + 联系表/静帧"),
586
+ else "web 80–130 ms/帧 + 启动 6 s", jobs_eff,
587
+ _speedup(jobs_eff), rerender))),
588
+ dict(stage="mux", lo=mux_lo, hi=mux_hi, measured=True,
589
+ note=("-c:v copy + 两遍 loudnorm(实测 29 s 片 1.7–1.9 s,与帧数无关);"
590
+ "reencode_video=true 才走 x264 %.0f fps" % X264_FPS)),
591
+ dict(stage="preflight", lo=pre_lo, hi=pre_hi, measured=True,
592
+ note="渲染前资源预检(缺字形/坏音频/缺字体;实测 0.4 s,渲一帧之前就失败)"),
593
+ dict(stage="qa", lo=qa_lo, hi=qa_hi, measured=True,
594
+ note=("联系表 + 锐度参考帧重渲染 + 帧级深检(实测 29 s 片 6.2–6.8 s,不是 1 s)")),
595
+ dict(stage="vmaf", lo=vmaf_lo, hi=vmaf_hi, measured=False, note=vmaf_note),
596
+ dict(stage="asr", lo=asr_lo, hi=asr_hi, measured=False,
597
+ note=("ASR 回读(faster-whisper small / CPU int8,RTF≈0.17 + 加载 2–3 s;"
598
+ "实测 29 s 片 6–10 s)"
599
+ if asr_on else "本次不跑(qa.asr.enabled=false)")),
436
600
  ]
437
601
  total_lo = sum(s["lo"] for s in stages)
438
602
  total_hi = sum(s["hi"] for s in stages)
@@ -443,9 +607,13 @@ def build_report(script_path, script, args):
443
607
  disk = disk_estimate(frames, total, n, n_cues, crf, music_on)
444
608
 
445
609
  # ---- 分块(render.py 规则)----
610
+ # 这里必须传**用户请求的 jobs**,不能传 jobs_eff:render.py 的 chunk_frames 公式用的是
611
+ # --jobs 原值(它不按核数封顶),而 jobs_eff 是给"加速比模型"封顶到 CORES 的。
612
+ # 传错的话 --jobs 16(24 线程机器上合法)会算出 chunk=120/8 段,render 实际是
613
+ # chunk=60/15 段——预演与实现"逐字一致"的承诺当场失效。
446
614
  explicit = int(_num(script.get("chunk_frames"), 0))
447
- chunk, nseg = chunk_plan(frames, fps, jobs_eff, explicit)
448
- chunk = dict(jobs=jobs_eff, jobs_requested=jobs_req, chunk_frames=chunk,
615
+ chunk, nseg = chunk_plan(frames, fps, jobs_req, explicit)
616
+ chunk = dict(jobs=jobs_req, jobs_requested=jobs_req, chunk_frames=chunk,
449
617
  segments=nseg, explicit=bool(explicit),
450
618
  rule="chunk = chunk_frames 或 round(frames/jobs/fps/4)*4*fps,"
451
619
  "再按 fps 取整、最小 2×fps")
@@ -456,7 +624,10 @@ def build_report(script_path, script, args):
456
624
  "source": source,
457
625
  "source_note": source_note,
458
626
  "video": {"title": script.get("title"),
459
- "width": W, "height": H, "fps": fps, "frames": frames,
627
+ # v0.8:内部构图恒为 1080p,交付尺寸可以更小(_vp 从 film.json 的 video 块来)
628
+ "width": _vp["w"], "height": _vp["h"],
629
+ "out_width": _vp["out_w"], "out_height": _vp["out_h"],
630
+ "fps": fps, "frames": frames,
460
631
  "duration_s": round(total, 3), "engine": engine,
461
632
  "crf": crf, "preset": preset, "bpm": mus.get("bpm", script.get("bpm")),
462
633
  "style": script.get("style", None)},
@@ -482,14 +653,24 @@ def build_report(script_path, script, args):
482
653
  "share": round(share, 3),
483
654
  "advice": bottleneck_advice(worst["stage"], engine, jobs_eff)},
484
655
  "assumptions": [
485
- "分辨率 1920x1080@%d 由 filmkit.W/H/FPS 写死,film.json 改不了" % FPS,
656
+ "内部构图恒为 1920x1080;交付尺寸/帧率可写 film.json 的 video 块"
657
+ "(v0.8,只允许 16:9 横屏且只能下采样;本次按 %d fps 估)" % fps,
486
658
  "lead=%.2f / tail=%.2f / min_dur=%.1f(v0.5 body 基准;剧本写了就以剧本为准)"
487
659
  % (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 的耗时为经验模型(无单阶段实测),量级可信、小数位不可信",
660
+ "tts 耗时按 %d 句 ÷ workers × %.1f–%.1f s(每句每 worker);实际受网络波动影响"
661
+ % (n, TTS_SEC_PER_LINE[0], TTS_SEC_PER_LINE[1]),
662
+ "frames 的并行不是 jobs 倍:有效加速比 = 1 + (jobs-1)×%.2f,封顶 %d 核"
663
+ % (PARALLEL_EFF, CORES),
664
+ "mux 默认 -c:v copy,只跟片长有关;reencode_video=true 才按帧数重编",
665
+ "layout/cues/audio/sfx 给 ±%d%% 经验区间(无单阶段实测)" % int(UNMEASURED_BAND * 100),
666
+ "v0.8 新增门禁口径:preflight 0.4 s 固定;vmaf ≈ %.2f–%.2f× 片长 + %.1f–%.1f s"
667
+ "(参考帧重渲染 + libvmaf;同机负载不同实测差近 2×);asr ≈ 0.18–0.35× 片长 + "
668
+ "3.5–6.5 s(faster-whisper small / CPU int8)"
669
+ % (VMAF_SEC_PER_SEC[0], VMAF_SEC_PER_SEC[1], VMAF_BASE_SEC[0], VMAF_BASE_SEC[1]),
670
+ "基准来源见 calibration 字段(机型/日期/样本);换机请重测后回填顶部常量",
491
671
  ],
492
672
  "baseline": BASELINE_NOTE,
673
+ "calibration": dict(CALIBRATION),
493
674
  }
494
675
  return report
495
676
 
@@ -545,6 +726,15 @@ def print_report(rep):
545
726
  % (bn["stage"], bn["seconds_hi"], bn["share"] * 100, bn["advice"]))
546
727
  print()
547
728
  print(" 基准说明:%s" % rep["baseline"])
729
+ cal = rep.get("calibration") or {}
730
+ if cal:
731
+ print(" 校准出处(换机请重测后回填 tools/estimate.py 顶部常量):")
732
+ print(" 机型 %s" % cal.get("machine", "-"))
733
+ print(" 实测 %s · 样本 %s" % (cal.get("measured_at", "-"), cal.get("film", "-")))
734
+ print(" 方法 %s" % cal.get("method", "-"))
735
+ obs = cal.get("observed") or {}
736
+ if obs:
737
+ print(" 实测值 " + " · ".join("%s %.1fs" % (k, v) for k, v in obs.items()))
548
738
  print(" 假设:")
549
739
  for a in rep["assumptions"]:
550
740
  print(" - %s" % a)