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

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.
@@ -0,0 +1,578 @@
1
+ # -*- coding: utf-8 -*-
2
+ """estimate.py — 成本/时长预演:跑 --stage all 之前,先算"要渲多久、占多少盘、怎么分块"。
3
+
4
+ 用法
5
+ ----
6
+ python tools/estimate.py --script film.json [--work work] [--jobs 4]
7
+ [--engine pil|web] [--rerender 1.4] [--json]
8
+
9
+ 做四件事
10
+ --------
11
+ 1. 帧数 / 时长 / fps / 分辨率(分辨率 1920x1080 在本项目写死,见 filmkit.W/H)
12
+ 2. 各阶段耗时:tts / layout / cues / score / sfx / audio / frames / mux / qa
13
+ 3. 磁盘:work 下 raw 管道峰值 + 成片体积估算
14
+ 4. 分块建议(与 render.py 的 chunk_frames 规则逐字一致)+ 最大瓶颈项
15
+
16
+ 输入口径
17
+ --------
18
+ 有 <work>/timeline.json(已排过轴)→ 用真实 total/frames,输出里注明"真实帧数";
19
+ 没有 → 按 lines 文本"等效字数 ÷ 4.2 字/秒 + lead/tail + hold"粗估,
20
+ 并在输出里**明确标注这是估算**(排过轴再跑一次就会换成真实数字)。
21
+
22
+ 退出码
23
+ ------
24
+ 0 正常;2 剧本不存在 / 不是合法 JSON(一句话错误,不甩 traceback)。
25
+ --json 时 stdout 只有 JSON(键名全英文),可直接 json.loads;警告走 stderr。
26
+
27
+ 基准来源
28
+ --------
29
+ 所有速度常量集中在下面"基准常量"一节,出处是 skills/video-studio/SKILL.md §12
30
+ (作者机器 2026-11-07 实测:Ryzen AI 9 HX 370 / 12C24T / 无 NVIDIA)。
31
+ 换机后先重测再采信——这些数字决定预演准不准,别凭感觉调。
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import math
38
+ import sys
39
+ import unicodedata
40
+ from pathlib import Path
41
+
42
+ # ============================================================================
43
+ # 基准常量(唯一出处:SKILL.md §12 本机实测;换机请重测)
44
+ # ============================================================================
45
+
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
48
+ WEB_SEC_PER_FRAME = (0.080, 0.130) # web 引擎:Edge 无头 PNG 截图 80–130 ms/帧
49
+ 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, 耗时低, 耗时高):两个实测点
53
+ 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
+ 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
60
+ CPS = 4.2 # 每秒约 4.2 字(voicekit 校准 3.84–4.61 cps 的中位口径)
61
+ DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR = 0.62, 0.92, 3.0 # v0.5 body 基准
62
+ CORES = 12 # HX 370:12C/24T;jobs > 12 不再线性加速
63
+ TTS_BYTES_PER_LINE = 32 * 1024 # 单句 mp3 缓存(4 s 语音约 24 KB,留余量)
64
+ WAV_BYTES_PER_SEC = 48000 * 2 * 2 # 48 kHz 立体声 PCM16 = 192 KB/s
65
+ VIDEO_MBPS = (1.2, 3.0) # crf17 @1080p30 图文内容(暗底大字 0.5–1 Mbps 属正常)
66
+ AUDIO_MBPS = 0.192 # AAC 192 kbps
67
+ QA_DISK_BYTES = 12 * 1024 * 1024 # 联系表 + 静帧 + deep.png
68
+ BASELINE_NOTE = ("区间来自作者机器实测(SKILL.md §12:Ryzen AI 9 HX 370 / 12C24T / "
69
+ "Radeon 890M 无 CUDA);换机请先重测再采信")
70
+
71
+ STAGE_LABELS = {
72
+ "tts": "旁白 edge-tts",
73
+ "layout": "排时间轴",
74
+ "cues": "画面提示点",
75
+ "score": "程序化配乐",
76
+ "sfx": "音效合成",
77
+ "audio": "三总线混音",
78
+ "frames": "逐帧渲染+编码",
79
+ "mux": "合成成片",
80
+ "qa": "质检+抽帧",
81
+ }
82
+
83
+
84
+ # ============================================================================
85
+ # 小工具
86
+ # ============================================================================
87
+
88
+ def _setup_stdio():
89
+ """输出被管道/文件重定向时把 stdout/stderr 切到 UTF-8(与 render.py 同一口径)。
90
+
91
+ Windows 下 python tools/estimate.py ... | tee log 的 stdout 默认是 GBK 代码页,
92
+ 中文会乱码;真实控制台保持系统编码,交互运行时中文照样正常。
93
+ """
94
+ for stream in (sys.stdout, sys.stderr):
95
+ try:
96
+ if stream is not None and not stream.isatty():
97
+ stream.reconfigure(encoding="utf-8", errors="replace")
98
+ except Exception: # noqa: BLE001
99
+ pass
100
+
101
+
102
+ def _die(msg, as_json=False):
103
+ """一句话错误 + 退出码 2(输入/用法错误,不是片子坏了)。"""
104
+ if as_json:
105
+ sys.stdout.write(json.dumps({"ok": False, "error": msg}, ensure_ascii=False) + "\n")
106
+ else:
107
+ sys.stderr.write("[estimate] %s\n" % msg)
108
+ raise SystemExit(2)
109
+
110
+
111
+ def _warn(msg):
112
+ """预演不阻断:警告一律走 stderr,--json 时 stdout 仍然只有 JSON。"""
113
+ sys.stderr.write("[estimate][WARN] %s\n" % msg)
114
+
115
+
116
+ def load_script(path, as_json=False):
117
+ """读剧本;坏输入给一句话错误(退出码 2),不甩 traceback。"""
118
+ p = Path(path).expanduser()
119
+ if not p.exists():
120
+ _die("找不到剧本 %s(--script 指到 film.json;模板在 skills/video-studio/assets/film.json)"
121
+ % p, as_json)
122
+ try:
123
+ raw = p.read_text(encoding="utf-8")
124
+ except OSError as e:
125
+ _die("读不了剧本 %s:%s" % (p, e), as_json)
126
+ except UnicodeDecodeError as e:
127
+ _die("%s 不是 UTF-8 文本(%s):用 UTF-8 另存 film.json" % (p, e), as_json)
128
+ try:
129
+ data = json.loads(raw)
130
+ except json.JSONDecodeError as e:
131
+ _die("%s 不是合法 JSON:第 %d 行第 %d 列 %s(先跑 python -m json.tool 看一眼)"
132
+ % (p, e.lineno, e.colno, e.msg), as_json)
133
+ if not isinstance(data, dict):
134
+ _die("%s 顶层必须是 JSON 对象(当前是 %s)" % (p, type(data).__name__), as_json)
135
+ return p, data
136
+
137
+
138
+ def _num(v, default=0.0):
139
+ """宽容取数:film.json 里写成字符串/None 时退回默认值(估算不该被脏字段炸掉)。"""
140
+ try:
141
+ return float(v)
142
+ except (TypeError, ValueError):
143
+ return float(default)
144
+
145
+
146
+ def _weighted_chars(text):
147
+ """等效字数:汉字 1;英文/数字 0.6;标点 0.3(停顿);空格 0。"""
148
+ n = 0.0
149
+ for ch in str(text or ""):
150
+ o = ord(ch)
151
+ if 0x3400 <= o <= 0x4DBF or 0x4E00 <= o <= 0x9FFF or 0xF900 <= o <= 0xFAFF:
152
+ n += 1.0
153
+ elif ch.isspace():
154
+ continue
155
+ elif ch.isascii() and ch.isalnum():
156
+ n += 0.6
157
+ else:
158
+ n += 0.3
159
+ return n
160
+
161
+
162
+ def _dw(s):
163
+ """显示宽度(中文按 2 列算),只用于表格对齐。"""
164
+ return sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 for ch in str(s))
165
+
166
+
167
+ def _pad(s, width):
168
+ s = str(s)
169
+ return s + " " * max(0, width - _dw(s))
170
+
171
+
172
+ def _fmt_bytes(n):
173
+ """十进制单位(1 GB = 10^9 B):和 SKILL.md §12 的口径一致(1080p30 的 30 s ≈5.6 GB)。"""
174
+ n = float(n)
175
+ for unit in ("B", "KB", "MB", "GB", "TB"):
176
+ if n < 1000.0 or unit == "TB":
177
+ if unit == "B":
178
+ return "%.0f B" % n
179
+ return "%.1f %s" % (n, unit)
180
+ n /= 1000.0
181
+
182
+
183
+ def _fmt_span(lo, hi):
184
+ if abs(float(hi) - float(lo)) < 0.05:
185
+ return "%.1f s" % float(lo)
186
+ return "%.1f–%.1f s" % (float(lo), float(hi))
187
+
188
+
189
+ # ============================================================================
190
+ # 估算核心
191
+ # ============================================================================
192
+
193
+ def estimate_timeline(script):
194
+ """没有 timeline.json 时:按"等效字数 ÷ 4.2 字/秒 + lead/tail + hold"粗估。
195
+
196
+ 与 filmkit.build_timeline 的公式同构:dur = max(min_dur, lead + voice + tail + hold)。
197
+ 只能说"量级对",误差通常 ±10–20%——这正是输出里必须标注"估算"的原因。
198
+ """
199
+ lines = [ln for ln in (script.get("lines") or []) if isinstance(ln, dict)]
200
+ lead = _num(script.get("lead", DEFAULT_LEAD), DEFAULT_LEAD)
201
+ tail = _num(script.get("tail", DEFAULT_TAIL), DEFAULT_TAIL)
202
+ min_dur = _num(script.get("min_dur", DEFAULT_MIN_DUR), DEFAULT_MIN_DUR)
203
+ ghold = _num(script.get("hold", 0.0))
204
+ scenes = []
205
+ for ln in lines:
206
+ voice = _weighted_chars(ln.get("text")) / CPS
207
+ hold = _num(ln.get("hold", ghold))
208
+ scenes.append(dict(id=str(ln.get("id") or ""), voice=voice, hold=hold,
209
+ dur=max(min_dur, lead + voice + tail + hold)))
210
+ return sum(s["dur"] for s in scenes), scenes
211
+
212
+
213
+ def read_timeline(work):
214
+ """读 <work>/timeline.json(已排轴的真实帧数)。缺失/坏文件返回 (None, 路径)。"""
215
+ p = Path(work) / "timeline.json"
216
+ if not p.exists():
217
+ return None, p
218
+ try:
219
+ tl = json.loads(p.read_text(encoding="utf-8"))
220
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
221
+ _warn("忽略 %s(读不了:%s),退回剧本粗估" % (p, e))
222
+ return None, p
223
+ if not isinstance(tl, dict) or "total" not in tl or "frames" not in tl:
224
+ _warn("忽略 %s(没有 total/frames 字段,不像本项目的 timeline),退回剧本粗估" % p)
225
+ return None, p
226
+ return tl, p
227
+
228
+
229
+ def score_seconds(total):
230
+ """配乐耗时:以 60 s / 389 s 两个实测点线性折算(超出范围按最近一段斜率外推)。"""
231
+ pts = [(r[0], r[1], r[2]) for r in SCORE_REFS]
232
+ if total <= pts[0][0]:
233
+ (x0, l0, h0), (x1, l1, h1) = pts[0], pts[1]
234
+ elif total >= pts[-1][0]:
235
+ (x0, l0, h0), (x1, l1, h1) = pts[-2], pts[-1]
236
+ else:
237
+ (x0, l0, h0), (x1, l1, h1) = pts[0], pts[1]
238
+ for i in range(len(pts) - 1):
239
+ if pts[i][0] <= total <= pts[i + 1][0]:
240
+ (x0, l0, h0), (x1, l1, h1) = pts[i], pts[i + 1]
241
+ break
242
+ k = (float(total) - x0) / float(x1 - x0) if x1 != x0 else 0.0
243
+ lo = l0 + (l1 - l0) * k
244
+ hi = h0 + (h1 - h0) * k
245
+ return max(0.3, lo), max(0.5, hi)
246
+
247
+
248
+ def frames_seconds(frames, jobs, engine, rerender):
249
+ """frames 阶段墙钟(只算画面生成,编码单列在 mux 行)。
250
+
251
+ PIL:帧数 ÷ 渲染 fps ÷ 并行度;web:启动开销 + 帧数 × 每帧截图耗时 ÷ 并行度。
252
+ render.py 的分块 worker 是"边渲染边编码",所以真实成本 ≈ 本行 + mux 行。
253
+ """
254
+ jobs_eff = max(1, min(int(jobs), CORES))
255
+ if engine == "web":
256
+ base = [WEB_STARTUP_SEC + frames * spf / jobs_eff for spf in WEB_SEC_PER_FRAME]
257
+ else:
258
+ base = [frames / rfps / jobs_eff for rfps in PIL_FPS]
259
+ base = sorted(base)
260
+ return base[0] * rerender, base[-1] * rerender
261
+
262
+
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
267
+
268
+
269
+ def chunk_plan(frames, fps, jobs, explicit):
270
+ """逐字复刻 render.py 的分块规则(改了那边记得同步这里)。
271
+
272
+ chunk = script.chunk_frames or round(frames/jobs/fps/4)*4*fps
273
+ chunk = max(fps*2, round(chunk/fps)*fps)
274
+ """
275
+ fps = int(fps) or FPS
276
+ jobs = max(1, int(jobs))
277
+ if explicit:
278
+ chunk = int(explicit)
279
+ else:
280
+ chunk = int(round(frames / float(jobs) / float(fps) / 4.0)) * 4 * fps
281
+ chunk = max(fps * 2, int(round(chunk / float(fps))) * int(fps))
282
+ nseg = int(math.ceil(frames / float(chunk))) if frames and chunk else 0
283
+ return chunk, nseg
284
+
285
+
286
+ def disk_estimate(frames, total, n_lines, n_cues, crf, music_on):
287
+ """磁盘:raw 管道峰值(不落盘但决定内存/管道带宽)+ work/ + out/。"""
288
+ try:
289
+ crf = int(crf)
290
+ except (TypeError, ValueError):
291
+ crf = 17
292
+ factor = 2.0 ** ((17.0 - crf) / 6.0) # crf 每降 6 体积约翻倍(x264 经验值)
293
+ vlo = max(0.3, VIDEO_MBPS[0] * factor)
294
+ vhi = max(0.3, VIDEO_MBPS[1] * factor)
295
+ vid_lo = total * vlo * 1e6 / 8.0
296
+ vid_hi = total * vhi * 1e6 / 8.0
297
+ out_lo = total * (vlo + AUDIO_MBPS) * 1e6 / 8.0
298
+ out_hi = total * (vhi + AUDIO_MBPS) * 1e6 / 8.0
299
+ n_wav = 1 + (1 if music_on else 0) + (1 if n_cues else 0)
300
+ wav_bytes = total * WAV_BYTES_PER_SEC
301
+ segments = vid_hi # 分块文件合计 ≈ 整片视频体积
302
+ silent = vid_hi
303
+ tts = n_lines * TTS_BYTES_PER_LINE
304
+ qa = QA_DISK_BYTES if frames else 0
305
+ work = tts + segments + silent + n_wav * wav_bytes + qa
306
+ return dict(raw_peak_bytes=W * H * 3 * int(frames),
307
+ work_bytes=work,
308
+ out_bytes_lo=out_lo, out_bytes_hi=out_hi,
309
+ breakdown=dict(tts_bytes=tts, segments_bytes=segments,
310
+ silent_bytes=silent, wav_bytes=n_wav * wav_bytes,
311
+ qa_bytes=qa),
312
+ video_mbps=(vlo, vhi))
313
+
314
+
315
+ def bottleneck_advice(stage, engine, jobs_eff):
316
+ if stage == "frames":
317
+ if engine == "web":
318
+ return ("换 engine=pil:同一画面语言下 PIL 比浏览器逐帧快 5–10 倍;"
319
+ "确实需要 Canvas/WebGL 才留 web(--jobs 提到 %d 也压不动截图往返的瓶颈)"
320
+ % CORES)
321
+ return ("把 --jobs 从 %d 提到 8–%d(本机 12 核)能把 frames 大致线性压下来;"
322
+ "再想快只能减帧或简化场景——1920x1080 是写死的,降分辨率不是选项"
323
+ % (jobs_eff, CORES))
324
+ if stage == "tts":
325
+ return "TTS 是网络等待:tts_workers 保持 4–6 就够,句子多先压台词,别靠并发硬堆"
326
+ if stage == "qa":
327
+ return "质检已是秒级;它随片长线性涨,是必要成本(不查等于没门禁)"
328
+ if stage == "score":
329
+ return "配乐是秒级纯 CPU 合成;片长再长也不该成为瓶颈"
330
+ return "该阶段占比高但绝对值不大:按上面的区间排期即可"
331
+
332
+
333
+ # ============================================================================
334
+ # 主流程
335
+ # ============================================================================
336
+
337
+ def build_report(script_path, script, args):
338
+ as_json = bool(args.as_json)
339
+ lines = [ln for ln in (script.get("lines") or []) if isinstance(ln, dict)]
340
+ if not lines:
341
+ _die("%s 的 lines 是空的:至少写一句(render.py 只接受非空数组)" % script_path, as_json)
342
+
343
+ engine = str(args.engine or script.get("engine") or "pil").lower()
344
+ if engine not in ("pil", "web"):
345
+ _warn("engine=%r 不认识(只能是 pil / web),按 pil 预演" % engine)
346
+ engine = "pil"
347
+
348
+ tl, tl_path = read_timeline(args.work)
349
+ if tl is not None:
350
+ total = _num(tl.get("total"))
351
+ fps = int(_num(tl.get("fps"), FPS)) or FPS
352
+ frames = int(_num(tl.get("frames"), round(total * fps)))
353
+ source = "timeline"
354
+ source_note = "真实帧数:%s" % tl_path
355
+ else:
356
+ total, _scenes = estimate_timeline(script)
357
+ fps = FPS
358
+ frames = int(round(total * fps))
359
+ source = "script_estimate"
360
+ source_note = ("粗估(等效字数 ÷ %.1f 字/秒 + lead/tail + hold;没有 %s)"
361
+ % (CPS, tl_path))
362
+ if total <= 0:
363
+ _die("%s 的 lines 里没有可估算的 text:每句都要有朗读文本" % script_path, as_json)
364
+
365
+ jobs_req = int(args.jobs)
366
+ if jobs_req < 1:
367
+ _warn("--jobs %d 无效,按 1 算" % jobs_req)
368
+ jobs_req = 1
369
+ jobs_eff = max(1, min(jobs_req, CORES))
370
+ if jobs_req > CORES:
371
+ _warn("--jobs %d 超过本机 12 核:分块并行到 12 之后基本不再线性加速" % jobs_req)
372
+
373
+ rerender = max(0.1, float(args.rerender))
374
+ n = len(lines)
375
+ sfx_cfg = script.get("sfx") or {}
376
+ mus = script.get("music") or {}
377
+ if not isinstance(sfx_cfg, dict):
378
+ sfx_cfg = {}
379
+ if not isinstance(mus, dict):
380
+ mus = {}
381
+ n_cues = len(sfx_cfg.get("cues") or [])
382
+ sfx_on = bool(sfx_cfg.get("enabled", True)) and n_cues > 0
383
+ # 配乐口径对齐 filmkit.music_plan:mode=off/none 或 enabled=false 才算关;
384
+ # 只有 mode=score(或没写 mode 但写了 bpm)才会真跑 scorekit。
385
+ mode = str(mus.get("mode") or "").strip().lower()
386
+ has_bpm = "bpm" in mus or "bpm" in script
387
+ music_on = bool(mus.get("enabled", True)) and mode not in ("off", "none")
388
+ score_on = music_on and (mode == "score" or (not mode and has_bpm))
389
+ crf = script.get("crf", 17)
390
+ preset = script.get("preset", "medium")
391
+
392
+ # ---- 各阶段 ----
393
+ workers = int(_num(script.get("tts_workers", 4), 4) or 4)
394
+ 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
398
+ if score_on:
399
+ score_lo, score_hi = score_seconds(total)
400
+ else:
401
+ 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
404
+ 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)
407
+
408
+ stages = [
409
+ 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),
416
+ 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×片长)"
425
+ % (AUDIO_BASE, AUDIO_PER_SEC)),
426
+ dict(stage="frames", lo=fr_lo, hi=fr_hi, measured=True,
427
+ note=("%s:%s,--jobs %d(本机 12 核)× 返工系数 %.2g;编码成本单列在 mux 行"
428
+ % (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,按片长线性折算 + 联系表/静帧"),
436
+ ]
437
+ total_lo = sum(s["lo"] for s in stages)
438
+ total_hi = sum(s["hi"] for s in stages)
439
+ worst = max(stages, key=lambda s: s["hi"])
440
+ share = worst["hi"] / total_hi if total_hi > 0 else 0.0
441
+
442
+ # ---- 磁盘 ----
443
+ disk = disk_estimate(frames, total, n, n_cues, crf, music_on)
444
+
445
+ # ---- 分块(render.py 规则)----
446
+ 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,
449
+ segments=nseg, explicit=bool(explicit),
450
+ rule="chunk = chunk_frames 或 round(frames/jobs/fps/4)*4*fps,"
451
+ "再按 fps 取整、最小 2×fps")
452
+
453
+ report = {
454
+ "ok": True,
455
+ "file": str(script_path),
456
+ "source": source,
457
+ "source_note": source_note,
458
+ "video": {"title": script.get("title"),
459
+ "width": W, "height": H, "fps": fps, "frames": frames,
460
+ "duration_s": round(total, 3), "engine": engine,
461
+ "crf": crf, "preset": preset, "bpm": mus.get("bpm", script.get("bpm")),
462
+ "style": script.get("style", None)},
463
+ "counts": {"lines": n, "cues": n_cues, "tts_workers": workers},
464
+ "jobs": {"requested": jobs_req, "effective": jobs_eff, "cores": CORES},
465
+ "rerender": rerender,
466
+ "stages": [dict(stage=s["stage"], label=STAGE_LABELS[s["stage"]],
467
+ seconds_lo=round(s["lo"], 2), seconds_hi=round(s["hi"], 2),
468
+ measured=s["measured"], note=s["note"]) for s in stages],
469
+ "total_seconds": {"lo": round(total_lo, 2), "hi": round(total_hi, 2)},
470
+ "disk": {"raw_peak_bytes": disk["raw_peak_bytes"],
471
+ "raw_peak_human": _fmt_bytes(disk["raw_peak_bytes"]),
472
+ "work_bytes": int(disk["work_bytes"]),
473
+ "work_human": _fmt_bytes(disk["work_bytes"]),
474
+ "out_bytes_lo": int(disk["out_bytes_lo"]),
475
+ "out_bytes_hi": int(disk["out_bytes_hi"]),
476
+ "out_human": "%s–%s" % (_fmt_bytes(disk["out_bytes_lo"]),
477
+ _fmt_bytes(disk["out_bytes_hi"])),
478
+ "video_mbps": [round(v, 2) for v in disk["video_mbps"]]},
479
+ "chunk": chunk,
480
+ "bottleneck": {"stage": worst["stage"],
481
+ "seconds_hi": round(worst["hi"], 2),
482
+ "share": round(share, 3),
483
+ "advice": bottleneck_advice(worst["stage"], engine, jobs_eff)},
484
+ "assumptions": [
485
+ "分辨率 1920x1080@%d 由 filmkit.W/H/FPS 写死,film.json 改不了" % FPS,
486
+ "lead=%.2f / tail=%.2f / min_dur=%.1f(v0.5 body 基准;剧本写了就以剧本为准)"
487
+ % (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 的耗时为经验模型(无单阶段实测),量级可信、小数位不可信",
491
+ ],
492
+ "baseline": BASELINE_NOTE,
493
+ }
494
+ return report
495
+
496
+
497
+ def print_report(rep):
498
+ """人读表格(--json 时不走这里,stdout 保持纯 JSON)。"""
499
+ v = rep["video"]
500
+ d = rep["disk"]
501
+ ch = rep["chunk"]
502
+ print("[estimate] %s" % rep["file"])
503
+ if rep["source"] == "timeline":
504
+ print(" 来源: %s(已排轴:用真实 total/frames)" % rep["source_note"])
505
+ else:
506
+ print(" 来源: %s" % rep["source_note"])
507
+ print(" 注意:这是**估算**,不是实测。跑过 --stage layout 后再跑本脚本,"
508
+ "会自动改用真实帧数。")
509
+ style = v.get("style")
510
+ if style is None:
511
+ style = "(没写 style 字段 = v0.4 legacy 行为)"
512
+ print(" 《%s》 片长 %.1f s / %d 帧 / %dx%d@%d / engine=%s / crf=%s preset=%s / style=%s"
513
+ % (v.get("title") or "(未写 title)", v["duration_s"], v["frames"], v["width"], v["height"],
514
+ v["fps"], v["engine"], v["crf"], v["preset"], style))
515
+ print(" 并行: --jobs %d(请求 %d;本机 12 核,>12 不再线性) 返工系数: %.2g"
516
+ % (ch["jobs"], ch["jobs_requested"], rep["rerender"]))
517
+ print()
518
+ print(" %s %s %s" % (_pad("阶段", 8), _pad("耗时", 16), "说明"))
519
+ for s in rep["stages"]:
520
+ span = _fmt_span(s["seconds_lo"], s["seconds_hi"])
521
+ tag = "" if s["measured"] else "[经验值] "
522
+ print(" %s %s %s"
523
+ % (_pad(s["stage"], 8), _pad(span, 16), tag + s["label"] + ":" + s["note"]))
524
+ total = _fmt_span(rep["total_seconds"]["lo"], rep["total_seconds"]["hi"])
525
+ print(" %s %s %s" % (_pad("合计", 8), _pad(total, 16), "--stage all 顺序跑完的墙钟(frames 并行)"))
526
+ print()
527
+ print(" 磁盘")
528
+ print(" raw 管道峰值 %s %d×%d×3 字节 × %d 帧(原始帧流式喂 x264,不落盘,"
529
+ "但决定内存/管道带宽)" % (_pad(d["raw_peak_human"], 10), W, H, v["frames"]))
530
+ print(" work/ %s segments + silent.mp4 + wav + tts + qa"
531
+ "(分块并行时 segments 与 silent 会同时存在)" % _pad(d["work_human"], 10))
532
+ print(" out/ %s 成片(视频 %.1f–%.1f Mbps + AAC 192 kbps)"
533
+ % (_pad(d["out_human"], 10), d["video_mbps"][0], d["video_mbps"][1]))
534
+ print()
535
+ print(" 分块建议(对齐 render.py:%s)" % ch["rule"])
536
+ print(" --jobs %d" % ch["jobs"])
537
+ print(" chunk_frames: %d 写进 film.json(render.py 没有 --chunk 参数,只从剧本读)"
538
+ % ch["chunk_frames"])
539
+ print(" 预计分块 %d 段 × 约 %d 帧" % (ch["segments"], ch["chunk_frames"]))
540
+ if ch["explicit"]:
541
+ print(" (剧本里已显式写了 chunk_frames,这里按它算)")
542
+ print()
543
+ bn = rep["bottleneck"]
544
+ print(" 最大瓶颈:%s ≈ %.1f s(占 %.0f%%)—— %s"
545
+ % (bn["stage"], bn["seconds_hi"], bn["share"] * 100, bn["advice"]))
546
+ print()
547
+ print(" 基准说明:%s" % rep["baseline"])
548
+ print(" 假设:")
549
+ for a in rep["assumptions"]:
550
+ print(" - %s" % a)
551
+
552
+
553
+ def main(argv=None):
554
+ _setup_stdio()
555
+ ap = argparse.ArgumentParser(
556
+ description="video-studio 成本/时长预演:渲多久、占多少盘、怎么分块")
557
+ ap.add_argument("--script", default="film.json", help="剧本路径(默认 film.json)")
558
+ ap.add_argument("--work", default="work", help="工作目录(有 timeline.json 就用真实帧数)")
559
+ ap.add_argument("--jobs", type=int, default=4, help="frames 阶段并行进程数(默认 4,本机上限 12)")
560
+ ap.add_argument("--engine", default="", choices=("", "pil", "web"),
561
+ help="覆盖 film.json 的 engine(空 = 按剧本,缺省 pil)")
562
+ ap.add_argument("--rerender", type=float, default=1.0,
563
+ help="返工系数:画面预计重渲几遍(1.0 = 一遍过;1.4 = 预留 40%%)")
564
+ ap.add_argument("--json", action="store_true", dest="as_json",
565
+ help="机读输出(stdout 只有 JSON,键名英文)")
566
+ args = ap.parse_args(argv)
567
+
568
+ script_path, script = load_script(args.script, bool(args.as_json))
569
+ rep = build_report(script_path, script, args)
570
+ if args.as_json:
571
+ sys.stdout.write(json.dumps(rep, ensure_ascii=False, indent=2) + "\n")
572
+ else:
573
+ print_report(rep)
574
+ return 0
575
+
576
+
577
+ if __name__ == "__main__":
578
+ sys.exit(main())