@dsh-gis/dsh-video-studio-skill 0.4.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.
- package/README.md +93 -18
- package/docs/styles/demo-results.json +76 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/index.js +20 -7
- package/package.json +13 -3
- package/schemas/film.schema.json +599 -0
- package/skills/video-studio/SKILL.md +252 -28
- package/skills/video-studio/assets/film.json +78 -24
- package/skills/video-studio/assets/scenes.py +1005 -42
- package/skills/video-studio/assets/web/engine.js +18 -1
- package/skills/video-studio/assets/web/film.js +29 -4
- package/skills/video-studio/assets/web/index.html +5 -0
- package/skills/video-studio/references/audio-variation.md +158 -0
- package/skills/video-studio/references/audio.md +96 -6
- package/skills/video-studio/references/change-impact.md +54 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/style-packs.md +178 -0
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/requirements.txt +13 -1
- package/skills/video-studio/scripts/filmcheck.py +725 -0
- package/skills/video-studio/scripts/filmkit.py +953 -76
- package/skills/video-studio/scripts/gates.py +509 -0
- package/skills/video-studio/scripts/qa_frames.py +109 -19
- package/skills/video-studio/scripts/render.py +942 -45
- package/skills/video-studio/scripts/scorekit.py +1887 -27
- package/skills/video-studio/scripts/selfcheck.py +648 -2
- package/skills/video-studio/scripts/sfxkit.py +132 -30
- package/skills/video-studio/scripts/stagewatch.py +405 -0
- package/skills/video-studio/scripts/stylekit.py +1012 -0
- package/skills/video-studio/scripts/voicekit.py +880 -0
- package/skills/video-studio/scripts/webengine.py +68 -14
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +450 -0
- package/tools/estimate.py +657 -0
- package/tools/selftest_noscipy.py +70 -0
|
@@ -0,0 +1,657 @@
|
|
|
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
|
+
# 基准常量(v0.7.2 重新校准:换机请重测后回填,别把这里的数字当通用真理)
|
|
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
|
+
PARALLEL_EFF = 0.5 # 分块并行的**实测效率**:2 进程只快 1.5×,不是 2×(见 CALIBRATION)
|
|
49
|
+
WEB_SEC_PER_FRAME = (0.080, 0.130) # web 引擎:Edge 无头 PNG 截图 80–130 ms/帧
|
|
50
|
+
WEB_STARTUP_SEC = 6.0 # 浏览器冷启动 + CDP 连接(12 s 片 28–30 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
|
+
# 第三个点是片长外推锚点(不是本机实测),只用来保证长片不会被系统性算小
|
|
56
|
+
SFX_SEC_PER_CUE = 0.20 # 单条短音效 合成+落位(24 配方单次 ≤1.5 s 是上限口径)
|
|
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)
|
|
60
|
+
AUDIO_BASE, AUDIO_PER_SEC = 0.6, 0.012 # numpy 总线按采样点叠加 + 写 wav
|
|
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
|
+
)
|
|
83
|
+
CPS = 4.2 # 每秒约 4.2 字(voicekit 校准 3.84–4.61 cps 的中位口径)
|
|
84
|
+
DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR = 0.62, 0.92, 3.0 # v0.5 body 基准
|
|
85
|
+
CORES = 12 # HX 370:12C/24T;jobs > 12 不再线性加速
|
|
86
|
+
TTS_BYTES_PER_LINE = 32 * 1024 # 单句 mp3 缓存(4 s 语音约 24 KB,留余量)
|
|
87
|
+
WAV_BYTES_PER_SEC = 48000 * 2 * 2 # 48 kHz 立体声 PCM16 = 192 KB/s
|
|
88
|
+
VIDEO_MBPS = (1.2, 3.0) # crf17 @1080p30 图文内容(暗底大字 0.5–1 Mbps 属正常)
|
|
89
|
+
AUDIO_MBPS = 0.192 # AAC 192 kbps
|
|
90
|
+
QA_DISK_BYTES = 12 * 1024 * 1024 # 联系表 + 静帧 + deep.png
|
|
91
|
+
BASELINE_NOTE = ("区间来自 v0.7.2 本机实测(机型/样本见下方 calibration;"
|
|
92
|
+
"SKILL.md §12 记录的作者机是 Ryzen AI 9 HX 370 / 12C24T);换机请先重测再采信")
|
|
93
|
+
|
|
94
|
+
STAGE_LABELS = {
|
|
95
|
+
"tts": "旁白 edge-tts",
|
|
96
|
+
"layout": "排时间轴",
|
|
97
|
+
"cues": "画面提示点",
|
|
98
|
+
"score": "程序化配乐",
|
|
99
|
+
"sfx": "音效合成",
|
|
100
|
+
"audio": "三总线混音",
|
|
101
|
+
"frames": "逐帧渲染+编码",
|
|
102
|
+
"mux": "合成成片",
|
|
103
|
+
"qa": "质检+抽帧",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ============================================================================
|
|
108
|
+
# 小工具
|
|
109
|
+
# ============================================================================
|
|
110
|
+
|
|
111
|
+
def _setup_stdio():
|
|
112
|
+
"""输出被管道/文件重定向时把 stdout/stderr 切到 UTF-8(与 render.py 同一口径)。
|
|
113
|
+
|
|
114
|
+
Windows 下 python tools/estimate.py ... | tee log 的 stdout 默认是 GBK 代码页,
|
|
115
|
+
中文会乱码;真实控制台保持系统编码,交互运行时中文照样正常。
|
|
116
|
+
"""
|
|
117
|
+
for stream in (sys.stdout, sys.stderr):
|
|
118
|
+
try:
|
|
119
|
+
if stream is not None and not stream.isatty():
|
|
120
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
121
|
+
except Exception: # noqa: BLE001
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _die(msg, as_json=False):
|
|
126
|
+
"""一句话错误 + 退出码 2(输入/用法错误,不是片子坏了)。"""
|
|
127
|
+
if as_json:
|
|
128
|
+
sys.stdout.write(json.dumps({"ok": False, "error": msg}, ensure_ascii=False) + "\n")
|
|
129
|
+
else:
|
|
130
|
+
sys.stderr.write("[estimate] %s\n" % msg)
|
|
131
|
+
raise SystemExit(2)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _warn(msg):
|
|
135
|
+
"""预演不阻断:警告一律走 stderr,--json 时 stdout 仍然只有 JSON。"""
|
|
136
|
+
sys.stderr.write("[estimate][WARN] %s\n" % msg)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_script(path, as_json=False):
|
|
140
|
+
"""读剧本;坏输入给一句话错误(退出码 2),不甩 traceback。"""
|
|
141
|
+
p = Path(path).expanduser()
|
|
142
|
+
if not p.exists():
|
|
143
|
+
_die("找不到剧本 %s(--script 指到 film.json;模板在 skills/video-studio/assets/film.json)"
|
|
144
|
+
% p, as_json)
|
|
145
|
+
try:
|
|
146
|
+
raw = p.read_text(encoding="utf-8")
|
|
147
|
+
except OSError as e:
|
|
148
|
+
_die("读不了剧本 %s:%s" % (p, e), as_json)
|
|
149
|
+
except UnicodeDecodeError as e:
|
|
150
|
+
_die("%s 不是 UTF-8 文本(%s):用 UTF-8 另存 film.json" % (p, e), as_json)
|
|
151
|
+
try:
|
|
152
|
+
data = json.loads(raw)
|
|
153
|
+
except json.JSONDecodeError as e:
|
|
154
|
+
_die("%s 不是合法 JSON:第 %d 行第 %d 列 %s(先跑 python -m json.tool 看一眼)"
|
|
155
|
+
% (p, e.lineno, e.colno, e.msg), as_json)
|
|
156
|
+
if not isinstance(data, dict):
|
|
157
|
+
_die("%s 顶层必须是 JSON 对象(当前是 %s)" % (p, type(data).__name__), as_json)
|
|
158
|
+
return p, data
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _num(v, default=0.0):
|
|
162
|
+
"""宽容取数:film.json 里写成字符串/None 时退回默认值(估算不该被脏字段炸掉)。"""
|
|
163
|
+
try:
|
|
164
|
+
return float(v)
|
|
165
|
+
except (TypeError, ValueError):
|
|
166
|
+
return float(default)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _weighted_chars(text):
|
|
170
|
+
"""等效字数:汉字 1;英文/数字 0.6;标点 0.3(停顿);空格 0。"""
|
|
171
|
+
n = 0.0
|
|
172
|
+
for ch in str(text or ""):
|
|
173
|
+
o = ord(ch)
|
|
174
|
+
if 0x3400 <= o <= 0x4DBF or 0x4E00 <= o <= 0x9FFF or 0xF900 <= o <= 0xFAFF:
|
|
175
|
+
n += 1.0
|
|
176
|
+
elif ch.isspace():
|
|
177
|
+
continue
|
|
178
|
+
elif ch.isascii() and ch.isalnum():
|
|
179
|
+
n += 0.6
|
|
180
|
+
else:
|
|
181
|
+
n += 0.3
|
|
182
|
+
return n
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _dw(s):
|
|
186
|
+
"""显示宽度(中文按 2 列算),只用于表格对齐。"""
|
|
187
|
+
return sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 for ch in str(s))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _pad(s, width):
|
|
191
|
+
s = str(s)
|
|
192
|
+
return s + " " * max(0, width - _dw(s))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _fmt_bytes(n):
|
|
196
|
+
"""十进制单位(1 GB = 10^9 B):和 SKILL.md §12 的口径一致(1080p30 的 30 s ≈5.6 GB)。"""
|
|
197
|
+
n = float(n)
|
|
198
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
199
|
+
if n < 1000.0 or unit == "TB":
|
|
200
|
+
if unit == "B":
|
|
201
|
+
return "%.0f B" % n
|
|
202
|
+
return "%.1f %s" % (n, unit)
|
|
203
|
+
n /= 1000.0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _fmt_span(lo, hi):
|
|
207
|
+
if abs(float(hi) - float(lo)) < 0.05:
|
|
208
|
+
return "%.1f s" % float(lo)
|
|
209
|
+
return "%.1f–%.1f s" % (float(lo), float(hi))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ============================================================================
|
|
213
|
+
# 估算核心
|
|
214
|
+
# ============================================================================
|
|
215
|
+
|
|
216
|
+
def estimate_timeline(script):
|
|
217
|
+
"""没有 timeline.json 时:按"等效字数 ÷ 4.2 字/秒 + lead/tail + hold"粗估。
|
|
218
|
+
|
|
219
|
+
与 filmkit.build_timeline 的公式同构:dur = max(min_dur, lead + voice + tail + hold)。
|
|
220
|
+
只能说"量级对",误差通常 ±10–20%——这正是输出里必须标注"估算"的原因。
|
|
221
|
+
"""
|
|
222
|
+
lines = [ln for ln in (script.get("lines") or []) if isinstance(ln, dict)]
|
|
223
|
+
lead = _num(script.get("lead", DEFAULT_LEAD), DEFAULT_LEAD)
|
|
224
|
+
tail = _num(script.get("tail", DEFAULT_TAIL), DEFAULT_TAIL)
|
|
225
|
+
min_dur = _num(script.get("min_dur", DEFAULT_MIN_DUR), DEFAULT_MIN_DUR)
|
|
226
|
+
ghold = _num(script.get("hold", 0.0))
|
|
227
|
+
scenes = []
|
|
228
|
+
for ln in lines:
|
|
229
|
+
voice = _weighted_chars(ln.get("text")) / CPS
|
|
230
|
+
hold = _num(ln.get("hold", ghold))
|
|
231
|
+
scenes.append(dict(id=str(ln.get("id") or ""), voice=voice, hold=hold,
|
|
232
|
+
dur=max(min_dur, lead + voice + tail + hold)))
|
|
233
|
+
return sum(s["dur"] for s in scenes), scenes
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def read_timeline(work):
|
|
237
|
+
"""读 <work>/timeline.json(已排轴的真实帧数)。缺失/坏文件返回 (None, 路径)。"""
|
|
238
|
+
p = Path(work) / "timeline.json"
|
|
239
|
+
if not p.exists():
|
|
240
|
+
return None, p
|
|
241
|
+
try:
|
|
242
|
+
tl = json.loads(p.read_text(encoding="utf-8"))
|
|
243
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
244
|
+
_warn("忽略 %s(读不了:%s),退回剧本粗估" % (p, e))
|
|
245
|
+
return None, p
|
|
246
|
+
if not isinstance(tl, dict) or "total" not in tl or "frames" not in tl:
|
|
247
|
+
_warn("忽略 %s(没有 total/frames 字段,不像本项目的 timeline),退回剧本粗估" % p)
|
|
248
|
+
return None, p
|
|
249
|
+
return tl, p
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def score_seconds(total):
|
|
253
|
+
"""配乐耗时:以 60 s / 389 s 两个实测点线性折算(超出范围按最近一段斜率外推)。"""
|
|
254
|
+
pts = [(r[0], r[1], r[2]) for r in SCORE_REFS]
|
|
255
|
+
if total <= pts[0][0]:
|
|
256
|
+
(x0, l0, h0), (x1, l1, h1) = pts[0], pts[1]
|
|
257
|
+
elif total >= pts[-1][0]:
|
|
258
|
+
(x0, l0, h0), (x1, l1, h1) = pts[-2], pts[-1]
|
|
259
|
+
else:
|
|
260
|
+
(x0, l0, h0), (x1, l1, h1) = pts[0], pts[1]
|
|
261
|
+
for i in range(len(pts) - 1):
|
|
262
|
+
if pts[i][0] <= total <= pts[i + 1][0]:
|
|
263
|
+
(x0, l0, h0), (x1, l1, h1) = pts[i], pts[i + 1]
|
|
264
|
+
break
|
|
265
|
+
k = (float(total) - x0) / float(x1 - x0) if x1 != x0 else 0.0
|
|
266
|
+
lo = l0 + (l1 - l0) * k
|
|
267
|
+
hi = h0 + (h1 - h0) * k
|
|
268
|
+
return max(0.3, lo), max(0.5, hi)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _speedup(jobs):
|
|
272
|
+
"""分块并行的**有效加速比**:不是 jobs 倍。
|
|
273
|
+
|
|
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 进程刚好对上。
|
|
278
|
+
"""
|
|
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)
|
|
290
|
+
if engine == "web":
|
|
291
|
+
base = [WEB_STARTUP_SEC + frames * spf / sp for spf in WEB_SEC_PER_FRAME]
|
|
292
|
+
else:
|
|
293
|
+
base = [frames / rfps / sp for rfps in PIL_FPS]
|
|
294
|
+
base = sorted(base)
|
|
295
|
+
return base[0] * rerender, base[-1] * rerender
|
|
296
|
+
|
|
297
|
+
|
|
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))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def chunk_plan(frames, fps, jobs, explicit):
|
|
321
|
+
"""逐字复刻 render.py 的分块规则(改了那边记得同步这里)。
|
|
322
|
+
|
|
323
|
+
chunk = script.chunk_frames or round(frames/jobs/fps/4)*4*fps
|
|
324
|
+
chunk = max(fps*2, round(chunk/fps)*fps)
|
|
325
|
+
"""
|
|
326
|
+
fps = int(fps) or FPS
|
|
327
|
+
jobs = max(1, int(jobs))
|
|
328
|
+
if explicit:
|
|
329
|
+
chunk = int(explicit)
|
|
330
|
+
else:
|
|
331
|
+
chunk = int(round(frames / float(jobs) / float(fps) / 4.0)) * 4 * fps
|
|
332
|
+
chunk = max(fps * 2, int(round(chunk / float(fps))) * int(fps))
|
|
333
|
+
nseg = int(math.ceil(frames / float(chunk))) if frames and chunk else 0
|
|
334
|
+
return chunk, nseg
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def disk_estimate(frames, total, n_lines, n_cues, crf, music_on):
|
|
338
|
+
"""磁盘:raw 管道峰值(不落盘但决定内存/管道带宽)+ work/ + out/。"""
|
|
339
|
+
try:
|
|
340
|
+
crf = int(crf)
|
|
341
|
+
except (TypeError, ValueError):
|
|
342
|
+
crf = 17
|
|
343
|
+
factor = 2.0 ** ((17.0 - crf) / 6.0) # crf 每降 6 体积约翻倍(x264 经验值)
|
|
344
|
+
vlo = max(0.3, VIDEO_MBPS[0] * factor)
|
|
345
|
+
vhi = max(0.3, VIDEO_MBPS[1] * factor)
|
|
346
|
+
vid_lo = total * vlo * 1e6 / 8.0
|
|
347
|
+
vid_hi = total * vhi * 1e6 / 8.0
|
|
348
|
+
out_lo = total * (vlo + AUDIO_MBPS) * 1e6 / 8.0
|
|
349
|
+
out_hi = total * (vhi + AUDIO_MBPS) * 1e6 / 8.0
|
|
350
|
+
n_wav = 1 + (1 if music_on else 0) + (1 if n_cues else 0)
|
|
351
|
+
wav_bytes = total * WAV_BYTES_PER_SEC
|
|
352
|
+
segments = vid_hi # 分块文件合计 ≈ 整片视频体积
|
|
353
|
+
silent = vid_hi
|
|
354
|
+
tts = n_lines * TTS_BYTES_PER_LINE
|
|
355
|
+
qa = QA_DISK_BYTES if frames else 0
|
|
356
|
+
work = tts + segments + silent + n_wav * wav_bytes + qa
|
|
357
|
+
return dict(raw_peak_bytes=W * H * 3 * int(frames),
|
|
358
|
+
work_bytes=work,
|
|
359
|
+
out_bytes_lo=out_lo, out_bytes_hi=out_hi,
|
|
360
|
+
breakdown=dict(tts_bytes=tts, segments_bytes=segments,
|
|
361
|
+
silent_bytes=silent, wav_bytes=n_wav * wav_bytes,
|
|
362
|
+
qa_bytes=qa),
|
|
363
|
+
video_mbps=(vlo, vhi))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def bottleneck_advice(stage, engine, jobs_eff):
|
|
367
|
+
if stage == "frames":
|
|
368
|
+
if engine == "web":
|
|
369
|
+
return ("换 engine=pil:同一画面语言下 PIL 比浏览器逐帧快 5–10 倍;"
|
|
370
|
+
"确实需要 Canvas/WebGL 才留 web(--jobs 提到 %d 也压不动截图往返的瓶颈)"
|
|
371
|
+
% CORES)
|
|
372
|
+
return ("把 --jobs 从 %d 提到 8–%d(本机 12 核)能把 frames 大致线性压下来;"
|
|
373
|
+
"再想快只能减帧或简化场景——1920x1080 是写死的,降分辨率不是选项"
|
|
374
|
+
% (jobs_eff, CORES))
|
|
375
|
+
if stage == "tts":
|
|
376
|
+
return "TTS 是网络等待:tts_workers 保持 4–6 就够,句子多先压台词,别靠并发硬堆"
|
|
377
|
+
if stage == "qa":
|
|
378
|
+
return "质检已是秒级;它随片长线性涨,是必要成本(不查等于没门禁)"
|
|
379
|
+
if stage == "score":
|
|
380
|
+
return "配乐是秒级纯 CPU 合成;片长再长也不该成为瓶颈"
|
|
381
|
+
return "该阶段占比高但绝对值不大:按上面的区间排期即可"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ============================================================================
|
|
385
|
+
# 主流程
|
|
386
|
+
# ============================================================================
|
|
387
|
+
|
|
388
|
+
def build_report(script_path, script, args):
|
|
389
|
+
as_json = bool(args.as_json)
|
|
390
|
+
lines = [ln for ln in (script.get("lines") or []) if isinstance(ln, dict)]
|
|
391
|
+
if not lines:
|
|
392
|
+
_die("%s 的 lines 是空的:至少写一句(render.py 只接受非空数组)" % script_path, as_json)
|
|
393
|
+
|
|
394
|
+
engine = str(args.engine or script.get("engine") or "pil").lower()
|
|
395
|
+
if engine not in ("pil", "web"):
|
|
396
|
+
_warn("engine=%r 不认识(只能是 pil / web),按 pil 预演" % engine)
|
|
397
|
+
engine = "pil"
|
|
398
|
+
|
|
399
|
+
tl, tl_path = read_timeline(args.work)
|
|
400
|
+
if tl is not None:
|
|
401
|
+
total = _num(tl.get("total"))
|
|
402
|
+
fps = int(_num(tl.get("fps"), FPS)) or FPS
|
|
403
|
+
frames = int(_num(tl.get("frames"), round(total * fps)))
|
|
404
|
+
source = "timeline"
|
|
405
|
+
source_note = "真实帧数:%s" % tl_path
|
|
406
|
+
else:
|
|
407
|
+
total, _scenes = estimate_timeline(script)
|
|
408
|
+
fps = FPS
|
|
409
|
+
frames = int(round(total * fps))
|
|
410
|
+
source = "script_estimate"
|
|
411
|
+
source_note = ("粗估(等效字数 ÷ %.1f 字/秒 + lead/tail + hold;没有 %s)"
|
|
412
|
+
% (CPS, tl_path))
|
|
413
|
+
if total <= 0:
|
|
414
|
+
_die("%s 的 lines 里没有可估算的 text:每句都要有朗读文本" % script_path, as_json)
|
|
415
|
+
|
|
416
|
+
jobs_req = int(args.jobs)
|
|
417
|
+
if jobs_req < 1:
|
|
418
|
+
_warn("--jobs %d 无效,按 1 算" % jobs_req)
|
|
419
|
+
jobs_req = 1
|
|
420
|
+
jobs_eff = max(1, min(jobs_req, CORES))
|
|
421
|
+
if jobs_req > CORES:
|
|
422
|
+
_warn("--jobs %d 超过本机 12 核:分块并行到 12 之后基本不再线性加速" % jobs_req)
|
|
423
|
+
|
|
424
|
+
rerender = max(0.1, float(args.rerender))
|
|
425
|
+
n = len(lines)
|
|
426
|
+
sfx_cfg = script.get("sfx") or {}
|
|
427
|
+
mus = script.get("music") or {}
|
|
428
|
+
if not isinstance(sfx_cfg, dict):
|
|
429
|
+
sfx_cfg = {}
|
|
430
|
+
if not isinstance(mus, dict):
|
|
431
|
+
mus = {}
|
|
432
|
+
n_cues = len(sfx_cfg.get("cues") or [])
|
|
433
|
+
sfx_on = bool(sfx_cfg.get("enabled", True)) and n_cues > 0
|
|
434
|
+
# 配乐口径对齐 filmkit.music_plan:mode=off/none 或 enabled=false 才算关;
|
|
435
|
+
# 只有 mode=score(或没写 mode 但写了 bpm)才会真跑 scorekit。
|
|
436
|
+
mode = str(mus.get("mode") or "").strip().lower()
|
|
437
|
+
has_bpm = "bpm" in mus or "bpm" in script
|
|
438
|
+
music_on = bool(mus.get("enabled", True)) and mode not in ("off", "none")
|
|
439
|
+
score_on = music_on and (mode == "score" or (not mode and has_bpm))
|
|
440
|
+
crf = script.get("crf", 17)
|
|
441
|
+
preset = script.get("preset", "medium")
|
|
442
|
+
|
|
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)
|
|
449
|
+
workers = int(_num(script.get("tts_workers", 4), 4) or 4)
|
|
450
|
+
workers = max(1, min(workers, max(1, 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)
|
|
456
|
+
if score_on:
|
|
457
|
+
score_lo, score_hi = score_seconds(total)
|
|
458
|
+
else:
|
|
459
|
+
score_lo = score_hi = 0.0
|
|
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)
|
|
462
|
+
fr_lo, fr_hi = frames_seconds(frames, jobs_eff, engine, rerender)
|
|
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])
|
|
466
|
+
|
|
467
|
+
stages = [
|
|
468
|
+
dict(stage="tts", lo=tts_lo, hi=tts_hi, measured=True,
|
|
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))),
|
|
478
|
+
dict(stage="score", lo=score_lo, hi=score_hi, measured=bool(score_on),
|
|
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×片长)"
|
|
489
|
+
% (AUDIO_BASE, AUDIO_PER_SEC)),
|
|
490
|
+
dict(stage="frames", lo=fr_lo, hi=fr_hi, measured=True,
|
|
491
|
+
note=("%s:%s,--jobs %d → 有效加速比 %.2f×(实测 2 进程 1.51×),"
|
|
492
|
+
"返工系数 %.2g;不重编码时封装成本在 mux 行"
|
|
493
|
+
% (engine, "PIL 40–80 fps" if engine == "pil"
|
|
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)")),
|
|
501
|
+
]
|
|
502
|
+
total_lo = sum(s["lo"] for s in stages)
|
|
503
|
+
total_hi = sum(s["hi"] for s in stages)
|
|
504
|
+
worst = max(stages, key=lambda s: s["hi"])
|
|
505
|
+
share = worst["hi"] / total_hi if total_hi > 0 else 0.0
|
|
506
|
+
|
|
507
|
+
# ---- 磁盘 ----
|
|
508
|
+
disk = disk_estimate(frames, total, n, n_cues, crf, music_on)
|
|
509
|
+
|
|
510
|
+
# ---- 分块(render.py 规则)----
|
|
511
|
+
explicit = int(_num(script.get("chunk_frames"), 0))
|
|
512
|
+
chunk, nseg = chunk_plan(frames, fps, jobs_eff, explicit)
|
|
513
|
+
chunk = dict(jobs=jobs_eff, jobs_requested=jobs_req, chunk_frames=chunk,
|
|
514
|
+
segments=nseg, explicit=bool(explicit),
|
|
515
|
+
rule="chunk = chunk_frames 或 round(frames/jobs/fps/4)*4*fps,"
|
|
516
|
+
"再按 fps 取整、最小 2×fps")
|
|
517
|
+
|
|
518
|
+
report = {
|
|
519
|
+
"ok": True,
|
|
520
|
+
"file": str(script_path),
|
|
521
|
+
"source": source,
|
|
522
|
+
"source_note": source_note,
|
|
523
|
+
"video": {"title": script.get("title"),
|
|
524
|
+
"width": W, "height": H, "fps": fps, "frames": frames,
|
|
525
|
+
"duration_s": round(total, 3), "engine": engine,
|
|
526
|
+
"crf": crf, "preset": preset, "bpm": mus.get("bpm", script.get("bpm")),
|
|
527
|
+
"style": script.get("style", None)},
|
|
528
|
+
"counts": {"lines": n, "cues": n_cues, "tts_workers": workers},
|
|
529
|
+
"jobs": {"requested": jobs_req, "effective": jobs_eff, "cores": CORES},
|
|
530
|
+
"rerender": rerender,
|
|
531
|
+
"stages": [dict(stage=s["stage"], label=STAGE_LABELS[s["stage"]],
|
|
532
|
+
seconds_lo=round(s["lo"], 2), seconds_hi=round(s["hi"], 2),
|
|
533
|
+
measured=s["measured"], note=s["note"]) for s in stages],
|
|
534
|
+
"total_seconds": {"lo": round(total_lo, 2), "hi": round(total_hi, 2)},
|
|
535
|
+
"disk": {"raw_peak_bytes": disk["raw_peak_bytes"],
|
|
536
|
+
"raw_peak_human": _fmt_bytes(disk["raw_peak_bytes"]),
|
|
537
|
+
"work_bytes": int(disk["work_bytes"]),
|
|
538
|
+
"work_human": _fmt_bytes(disk["work_bytes"]),
|
|
539
|
+
"out_bytes_lo": int(disk["out_bytes_lo"]),
|
|
540
|
+
"out_bytes_hi": int(disk["out_bytes_hi"]),
|
|
541
|
+
"out_human": "%s–%s" % (_fmt_bytes(disk["out_bytes_lo"]),
|
|
542
|
+
_fmt_bytes(disk["out_bytes_hi"])),
|
|
543
|
+
"video_mbps": [round(v, 2) for v in disk["video_mbps"]]},
|
|
544
|
+
"chunk": chunk,
|
|
545
|
+
"bottleneck": {"stage": worst["stage"],
|
|
546
|
+
"seconds_hi": round(worst["hi"], 2),
|
|
547
|
+
"share": round(share, 3),
|
|
548
|
+
"advice": bottleneck_advice(worst["stage"], engine, jobs_eff)},
|
|
549
|
+
"assumptions": [
|
|
550
|
+
"分辨率 1920x1080@%d 由 filmkit.W/H/FPS 写死,film.json 改不了" % FPS,
|
|
551
|
+
"lead=%.2f / tail=%.2f / min_dur=%.1f(v0.5 body 基准;剧本写了就以剧本为准)"
|
|
552
|
+
% (DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR),
|
|
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 字段(机型/日期/样本);换机请重测后回填顶部常量",
|
|
560
|
+
],
|
|
561
|
+
"baseline": BASELINE_NOTE,
|
|
562
|
+
"calibration": dict(CALIBRATION),
|
|
563
|
+
}
|
|
564
|
+
return report
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def print_report(rep):
|
|
568
|
+
"""人读表格(--json 时不走这里,stdout 保持纯 JSON)。"""
|
|
569
|
+
v = rep["video"]
|
|
570
|
+
d = rep["disk"]
|
|
571
|
+
ch = rep["chunk"]
|
|
572
|
+
print("[estimate] %s" % rep["file"])
|
|
573
|
+
if rep["source"] == "timeline":
|
|
574
|
+
print(" 来源: %s(已排轴:用真实 total/frames)" % rep["source_note"])
|
|
575
|
+
else:
|
|
576
|
+
print(" 来源: %s" % rep["source_note"])
|
|
577
|
+
print(" 注意:这是**估算**,不是实测。跑过 --stage layout 后再跑本脚本,"
|
|
578
|
+
"会自动改用真实帧数。")
|
|
579
|
+
style = v.get("style")
|
|
580
|
+
if style is None:
|
|
581
|
+
style = "(没写 style 字段 = v0.4 legacy 行为)"
|
|
582
|
+
print(" 《%s》 片长 %.1f s / %d 帧 / %dx%d@%d / engine=%s / crf=%s preset=%s / style=%s"
|
|
583
|
+
% (v.get("title") or "(未写 title)", v["duration_s"], v["frames"], v["width"], v["height"],
|
|
584
|
+
v["fps"], v["engine"], v["crf"], v["preset"], style))
|
|
585
|
+
print(" 并行: --jobs %d(请求 %d;本机 12 核,>12 不再线性) 返工系数: %.2g"
|
|
586
|
+
% (ch["jobs"], ch["jobs_requested"], rep["rerender"]))
|
|
587
|
+
print()
|
|
588
|
+
print(" %s %s %s" % (_pad("阶段", 8), _pad("耗时", 16), "说明"))
|
|
589
|
+
for s in rep["stages"]:
|
|
590
|
+
span = _fmt_span(s["seconds_lo"], s["seconds_hi"])
|
|
591
|
+
tag = "" if s["measured"] else "[经验值] "
|
|
592
|
+
print(" %s %s %s"
|
|
593
|
+
% (_pad(s["stage"], 8), _pad(span, 16), tag + s["label"] + ":" + s["note"]))
|
|
594
|
+
total = _fmt_span(rep["total_seconds"]["lo"], rep["total_seconds"]["hi"])
|
|
595
|
+
print(" %s %s %s" % (_pad("合计", 8), _pad(total, 16), "--stage all 顺序跑完的墙钟(frames 并行)"))
|
|
596
|
+
print()
|
|
597
|
+
print(" 磁盘")
|
|
598
|
+
print(" raw 管道峰值 %s %d×%d×3 字节 × %d 帧(原始帧流式喂 x264,不落盘,"
|
|
599
|
+
"但决定内存/管道带宽)" % (_pad(d["raw_peak_human"], 10), W, H, v["frames"]))
|
|
600
|
+
print(" work/ %s segments + silent.mp4 + wav + tts + qa"
|
|
601
|
+
"(分块并行时 segments 与 silent 会同时存在)" % _pad(d["work_human"], 10))
|
|
602
|
+
print(" out/ %s 成片(视频 %.1f–%.1f Mbps + AAC 192 kbps)"
|
|
603
|
+
% (_pad(d["out_human"], 10), d["video_mbps"][0], d["video_mbps"][1]))
|
|
604
|
+
print()
|
|
605
|
+
print(" 分块建议(对齐 render.py:%s)" % ch["rule"])
|
|
606
|
+
print(" --jobs %d" % ch["jobs"])
|
|
607
|
+
print(" chunk_frames: %d 写进 film.json(render.py 没有 --chunk 参数,只从剧本读)"
|
|
608
|
+
% ch["chunk_frames"])
|
|
609
|
+
print(" 预计分块 %d 段 × 约 %d 帧" % (ch["segments"], ch["chunk_frames"]))
|
|
610
|
+
if ch["explicit"]:
|
|
611
|
+
print(" (剧本里已显式写了 chunk_frames,这里按它算)")
|
|
612
|
+
print()
|
|
613
|
+
bn = rep["bottleneck"]
|
|
614
|
+
print(" 最大瓶颈:%s ≈ %.1f s(占 %.0f%%)—— %s"
|
|
615
|
+
% (bn["stage"], bn["seconds_hi"], bn["share"] * 100, bn["advice"]))
|
|
616
|
+
print()
|
|
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()))
|
|
627
|
+
print(" 假设:")
|
|
628
|
+
for a in rep["assumptions"]:
|
|
629
|
+
print(" - %s" % a)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def main(argv=None):
|
|
633
|
+
_setup_stdio()
|
|
634
|
+
ap = argparse.ArgumentParser(
|
|
635
|
+
description="video-studio 成本/时长预演:渲多久、占多少盘、怎么分块")
|
|
636
|
+
ap.add_argument("--script", default="film.json", help="剧本路径(默认 film.json)")
|
|
637
|
+
ap.add_argument("--work", default="work", help="工作目录(有 timeline.json 就用真实帧数)")
|
|
638
|
+
ap.add_argument("--jobs", type=int, default=4, help="frames 阶段并行进程数(默认 4,本机上限 12)")
|
|
639
|
+
ap.add_argument("--engine", default="", choices=("", "pil", "web"),
|
|
640
|
+
help="覆盖 film.json 的 engine(空 = 按剧本,缺省 pil)")
|
|
641
|
+
ap.add_argument("--rerender", type=float, default=1.0,
|
|
642
|
+
help="返工系数:画面预计重渲几遍(1.0 = 一遍过;1.4 = 预留 40%%)")
|
|
643
|
+
ap.add_argument("--json", action="store_true", dest="as_json",
|
|
644
|
+
help="机读输出(stdout 只有 JSON,键名英文)")
|
|
645
|
+
args = ap.parse_args(argv)
|
|
646
|
+
|
|
647
|
+
script_path, script = load_script(args.script, bool(args.as_json))
|
|
648
|
+
rep = build_report(script_path, script, args)
|
|
649
|
+
if args.as_json:
|
|
650
|
+
sys.stdout.write(json.dumps(rep, ensure_ascii=False, indent=2) + "\n")
|
|
651
|
+
else:
|
|
652
|
+
print_report(rep)
|
|
653
|
+
return 0
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
if __name__ == "__main__":
|
|
657
|
+
sys.exit(main())
|