@dsh-gis/dsh-video-studio-skill 0.6.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +951 -0
- package/README.md +89 -21
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +23 -7
- package/package.json +7 -3
- package/schemas/film.schema.json +235 -4
- package/skills/video-studio/SKILL.md +299 -27
- package/skills/video-studio/assets/film.json +7 -14
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +115 -10
- package/skills/video-studio/assets/web/engine.js +81 -3
- package/skills/video-studio/assets/web/film.js +249 -101
- package/skills/video-studio/assets/web/index.html +9 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +159 -0
- package/skills/video-studio/references/audio.md +67 -5
- package/skills/video-studio/references/change-impact.md +2 -2
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +16 -1
- package/skills/video-studio/scripts/filmcheck.py +202 -12
- package/skills/video-studio/scripts/filmkit.py +792 -56
- package/skills/video-studio/scripts/gates.py +161 -14
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +154 -23
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +1134 -78
- package/skills/video-studio/scripts/scorekit.py +305 -76
- package/skills/video-studio/scripts/selfcheck.py +576 -2
- package/skills/video-studio/scripts/sfxkit.py +262 -44
- package/skills/video-studio/scripts/stagewatch.py +159 -36
- package/skills/video-studio/scripts/stylekit.py +137 -18
- package/skills/video-studio/scripts/voicekit.py +23 -7
- package/skills/video-studio/scripts/webengine.py +1186 -85
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +310 -59
- package/tools/estimate.py +251 -61
- package/tools/golden_frames.py +664 -0
- package/tools/selftest_noscipy.py +70 -0
package/tools/estimate.py
CHANGED
|
@@ -40,23 +40,54 @@ import unicodedata
|
|
|
40
40
|
from pathlib import Path
|
|
41
41
|
|
|
42
42
|
# ============================================================================
|
|
43
|
-
#
|
|
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
|
|
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
|
|
52
|
-
SCORE_REFS = ((
|
|
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.
|
|
55
|
-
LAYOUT_BASE, LAYOUT_PER_LINE = 0.
|
|
56
|
-
CUES_BASE, CUES_PER_LINE = 0.
|
|
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
|
-
|
|
59
|
-
|
|
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 = ("
|
|
69
|
-
"
|
|
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
|
|
249
|
-
"""
|
|
308
|
+
def _speedup(jobs):
|
|
309
|
+
"""分块并行的**有效加速比**:不是 jobs 倍。
|
|
250
310
|
|
|
251
|
-
|
|
252
|
-
|
|
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 /
|
|
328
|
+
base = [WEB_STARTUP_SEC + frames * spf / sp for spf in WEB_SEC_PER_FRAME]
|
|
257
329
|
else:
|
|
258
|
-
base = [frames / rfps /
|
|
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
|
|
264
|
-
"""
|
|
265
|
-
|
|
266
|
-
|
|
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"),
|
|
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
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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 句
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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=("
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
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,
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
%
|
|
434
|
-
dict(stage="
|
|
435
|
-
note="
|
|
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,
|
|
448
|
-
chunk = dict(jobs=
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
489
|
-
|
|
490
|
-
"
|
|
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)
|