@dsh-gis/dsh-video-studio-skill 0.7.2 → 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 +51 -8
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +4 -1
- package/package.json +5 -3
- package/schemas/film.schema.json +162 -2
- package/skills/video-studio/SKILL.md +211 -11
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/web/engine.js +63 -2
- package/skills/video-studio/assets/web/film.js +224 -101
- package/skills/video-studio/assets/web/index.html +4 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +4 -3
- package/skills/video-studio/references/audio.md +2 -2
- package/skills/video-studio/references/change-impact.md +1 -1
- 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 +14 -0
- package/skills/video-studio/scripts/filmcheck.py +121 -0
- package/skills/video-studio/scripts/filmkit.py +521 -38
- package/skills/video-studio/scripts/gates.py +88 -4
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +49 -8
- 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 +719 -32
- package/skills/video-studio/scripts/scorekit.py +16 -30
- package/skills/video-studio/scripts/selfcheck.py +274 -0
- package/skills/video-studio/scripts/sfxkit.py +130 -14
- package/skills/video-studio/scripts/stagewatch.py +19 -6
- package/skills/video-studio/scripts/stylekit.py +43 -12
- package/skills/video-studio/scripts/voicekit.py +15 -7
- package/skills/video-studio/scripts/webengine.py +1119 -72
- package/tools/ci_check.py +171 -31
- package/tools/estimate.py +130 -19
- package/tools/golden_frames.py +664 -0
package/tools/ci_check.py
CHANGED
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
11 体积:单个文件不超过 400 KB(技能要能塞进上下文与包里)
|
|
24
24
|
12 film.json 契约:schemas/film.schema.json + scripts/filmcheck.py 校验模板(规则只留一份)
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
三档结论(与技能自己的门禁账本同口径):PASS = 这次真的查了且通过;FAIL = 查了没过;
|
|
27
|
+
SKIP = **这次没执行**(比如本地没装 node / npm),**不等于通过**。
|
|
28
|
+
|
|
29
|
+
退出码:0 = 无 FAIL;1 = 有 FAIL。加 --strict 时 SKIP 也算失败(本地环境缺工具时用它自证"真的全查了")。
|
|
30
|
+
每一项都打印证据,不打"可能有问题"。
|
|
27
31
|
"""
|
|
28
32
|
from __future__ import annotations
|
|
29
33
|
|
|
@@ -44,29 +48,51 @@ TOOLS = ROOT / "tools"
|
|
|
44
48
|
REFS = SKILL / "references"
|
|
45
49
|
|
|
46
50
|
# 技能允许的三方依赖(与 README「环境前提」一一对应;加东西必须先改文档)
|
|
47
|
-
ALLOWED_THIRD_PARTY = {"numpy", "PIL", "cv2", "scipy", "edge_tts", "playwright"
|
|
51
|
+
ALLOWED_THIRD_PARTY = {"numpy", "PIL", "cv2", "scipy", "edge_tts", "playwright",
|
|
52
|
+
# v0.8:ASR 回读门禁(可选,缺了 qaasr 自动降级到 ffmpeg whisper / skipped)
|
|
53
|
+
"faster_whisper"}
|
|
48
54
|
# 同目录同级模块(拷走整个目录后仍能互相 import)
|
|
49
55
|
LOCAL_MODULES = {"filmkit", "sfxkit", "scorekit", "webengine", "qa_frames", "render", "selfcheck",
|
|
50
56
|
"stylekit", "voicekit", "scenes", "scenes_demo",
|
|
51
57
|
# v0.6.0:门禁账本 / 交付报告 / 签核、阶段新鲜度守卫、film.json 契约校验器
|
|
52
|
-
"gates", "stagewatch", "filmcheck"
|
|
58
|
+
"gates", "stagewatch", "filmcheck",
|
|
59
|
+
# v0.8.0:渲染前资源闸门、全片 VMAF/SSIM 门禁、ASR 回读门禁
|
|
60
|
+
"preflight", "qavmaf", "qaasr"}
|
|
53
61
|
MAX_FILE_BYTES = 400 * 1024
|
|
62
|
+
# 体积检查忽略的目录后缀:.team 是本地调研/自测工作区(gitignore),不随包发布;
|
|
63
|
+
# 媒体文件本来就该大(样例片/参考视频),不该拿"能不能塞进上下文"的尺子量。
|
|
64
|
+
SIZE_SKIP_DIRS = {".git", "node_modules", ".team", "work", "out", "__pycache__", ".venv", "venv"}
|
|
65
|
+
SIZE_SKIP_EXT = {".mp4", ".mp3", ".wav", ".mov", ".webm", ".gif", ".webp", ".zip", ".tgz"}
|
|
66
|
+
|
|
67
|
+
_results: list[tuple[str, str, str]] = []
|
|
68
|
+
|
|
69
|
+
# 「跳过」的前缀约定:检查函数返回这些开头的字符串表示**这次没有真的查**。
|
|
70
|
+
# 以前这里只有 bool 两态,"跳过(没装 node)"会被记成 PASS——正是本仓库反复声明的
|
|
71
|
+
# "skipped != 通过",却在仓库级体检自己的汇总里破了例(本地没装 node 的人会看到
|
|
72
|
+
# PASS 12/12,其实第 2、6 项根本没跑)。现在三档:PASS / FAIL / SKIP。
|
|
73
|
+
SKIP_PREFIXES = ("跳过", "skip")
|
|
74
|
+
|
|
54
75
|
|
|
55
|
-
|
|
76
|
+
def _is_skip(detail: str) -> bool:
|
|
77
|
+
head = str(detail or "").strip().lower()
|
|
78
|
+
return any(head.startswith(p) for p in SKIP_PREFIXES)
|
|
56
79
|
|
|
57
80
|
|
|
58
81
|
def check(name: str, fn):
|
|
59
|
-
"""跑一项检查;返回
|
|
82
|
+
"""跑一项检查;返回 PASS / FAIL / SKIP 三态,detail 保留证据或原因。"""
|
|
60
83
|
try:
|
|
61
84
|
detail = fn() or ""
|
|
62
|
-
|
|
63
|
-
|
|
85
|
+
if _is_skip(detail):
|
|
86
|
+
_results.append(("SKIP", name, str(detail)))
|
|
87
|
+
return "SKIP"
|
|
88
|
+
_results.append(("PASS", name, str(detail)))
|
|
89
|
+
return "PASS"
|
|
64
90
|
except AssertionError as e:
|
|
65
|
-
_results.append((
|
|
66
|
-
return
|
|
91
|
+
_results.append(("FAIL", name, str(e)))
|
|
92
|
+
return "FAIL"
|
|
67
93
|
except Exception as e: # noqa: BLE001
|
|
68
|
-
_results.append((
|
|
69
|
-
return
|
|
94
|
+
_results.append(("FAIL", name, "%s: %s" % (type(e).__name__, e)))
|
|
95
|
+
return "FAIL"
|
|
70
96
|
|
|
71
97
|
|
|
72
98
|
# ---------------------------------------------------------------- 1 语法
|
|
@@ -141,7 +167,16 @@ def t_json():
|
|
|
141
167
|
bad_badges = [b for b in badges if b != ver]
|
|
142
168
|
assert not bad_badges, ("README 徽章版本 %s 与 package.json version %s 不一致"
|
|
143
169
|
% ("/".join(bad_badges), ver))
|
|
144
|
-
|
|
170
|
+
# CHANGELOG 的第一条标题也必须是当前版本:以前只查 README 徽章,于是"发了新版本但
|
|
171
|
+
# CHANGELOG 顶部还是上一版"没人发现(历史真发生过,见 v0.7.2 的修复记录)。
|
|
172
|
+
changelog = ROOT / "CHANGELOG.md"
|
|
173
|
+
assert changelog.is_file(), "缺 CHANGELOG.md"
|
|
174
|
+
m = re.search(r"^##\s+(\d+\.\d+\.\d+)", changelog.read_text(encoding="utf-8"), re.M)
|
|
175
|
+
assert m, "CHANGELOG.md 里找不到 \"## X.Y.Z\" 形式的版本标题"
|
|
176
|
+
assert m.group(1) == ver, ("CHANGELOG 顶部版本 %s 与 package.json version %s 不一致"
|
|
177
|
+
"(发版时两处必须同时改)" % (m.group(1), ver))
|
|
178
|
+
return ("%d 个 .json 全部可解析;version=%s 与 README 徽章 / CHANGELOG 顶部一致"
|
|
179
|
+
% (len(files), ver))
|
|
145
180
|
|
|
146
181
|
|
|
147
182
|
# ---------------------------------------------------------------- 3 技能清单
|
|
@@ -197,22 +232,55 @@ def t_pack():
|
|
|
197
232
|
files = [f["path"] for f in meta.get("files", [])]
|
|
198
233
|
# 垃圾文件可能出现在**包内任意位置**(实测踩过:队友的临时目录建在 skills/ 下,
|
|
199
234
|
# 于是 skills/tmp_scratch_x/webtest/*.mp4 被一起打进包,体量翻倍)。
|
|
200
|
-
junk_suffix = (".pyc", ".pyo", ".log", ".tmp", ".orig", ".bak", ".mp4", ".wav", ".mkv", ".tgz"
|
|
235
|
+
junk_suffix = (".pyc", ".pyo", ".log", ".tmp", ".orig", ".bak", ".mp4", ".wav", ".mkv", ".tgz",
|
|
236
|
+
# 二进制/可执行文件:技能包里不该有,出现就是某个进程把缓存写进了源码树。
|
|
237
|
+
# 实测踩过:输入法按 %SystemDrive%\ProgramData\...\*.bin 往 cwd 落缓存,
|
|
238
|
+
# 当时 cwd 正好是 skills/video-studio/scripts/,两个 172 KB 的 .bin 进了包,
|
|
239
|
+
# 而旧的 junk 规则只看 .pyc/.log/.mp4 这些后缀,检查照样 PASS。
|
|
240
|
+
".bin", ".dll", ".exe", ".so", ".dylib", ".pyd", ".db", ".sqlite")
|
|
201
241
|
junk_parts = ("__pycache__", "tmp_scratch_", "node_modules", ".pytest_cache", ".cordis")
|
|
202
242
|
junk = [f for f in files
|
|
203
243
|
if f.endswith(junk_suffix) or any(p in f for p in junk_parts)
|
|
204
|
-
or f.startswith(("work/", "out/", ".team/"))
|
|
244
|
+
or f.startswith(("work/", "out/", ".team/"))
|
|
245
|
+
# 路径里带 % 段 = 某个环境变量没被展开(Windows 上的典型坏路径)。
|
|
246
|
+
# 发布包里不可能有合法的含 % 路径段,直接判垃圾,而不是去猜是哪个变量。
|
|
247
|
+
or any("%" in part for part in f.split("/"))]
|
|
205
248
|
assert not junk, ("包内不该有这些文件(临时产物/日志/媒体,属于打包污染):%s"
|
|
206
249
|
% ", ".join(junk[:6]))
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
"skills/video-studio/scripts/render.py", "skills/video-studio/scripts/selfcheck.py",
|
|
210
|
-
"skills/video-studio/assets/film.json", "skills/video-studio/references/quality-gates.md",
|
|
211
|
-
"schemas/film.schema.json", "tools/ci_check.py", "tools/estimate.py",
|
|
212
|
-
"tools/audio_similarity.py", "tools/selftest_noscipy.py"]
|
|
250
|
+
# 根文件清单仍然手写:它们少一个插件就装不起来,而且数量少、变动少。
|
|
251
|
+
must = ["index.js", "cordis.patch.yml", "icon.svg", "package.json", "README.md"]
|
|
213
252
|
missing = [m for m in must if m not in files]
|
|
214
253
|
assert not missing, "包内缺文件:%s" % ", ".join(missing)
|
|
215
|
-
|
|
254
|
+
# 目录部分改成**派生式**:skills/ schemas/ locale/ tools/ docs/ 下磁盘上有的(除缓存/字节码)
|
|
255
|
+
# 必须全部进包。以前这里是手写 must 列表,于是 v0.6 新增的 gates.py / stagewatch.py、
|
|
256
|
+
# v0.8 新增的 preflight.py / qavmaf.py / qaasr.py / tools/golden_frames.py 都不在列表里——
|
|
257
|
+
# "必需文件必须在包里"这条检查对**新增文件天然免疫**,等于只保护了第一版写下的那些路径。
|
|
258
|
+
shipped_dirs = ("skills", "schemas", "locale", "tools", "docs")
|
|
259
|
+
on_disk = []
|
|
260
|
+
for d in shipped_dirs:
|
|
261
|
+
base = ROOT / d
|
|
262
|
+
if not base.is_dir():
|
|
263
|
+
continue
|
|
264
|
+
for p in sorted(base.rglob("*")):
|
|
265
|
+
if not p.is_file():
|
|
266
|
+
continue
|
|
267
|
+
if "__pycache__" in p.parts or p.suffix == ".pyc":
|
|
268
|
+
continue
|
|
269
|
+
rel = p.relative_to(ROOT).as_posix()
|
|
270
|
+
# 命中 junk 的文件不参与"必须进包"的比对:它们本来就不该进包,
|
|
271
|
+
# 而"磁盘上有就必须进包"会把垃圾反向当成必需文件(实测:给 package.json 加
|
|
272
|
+
# "!skills/**/*.bin" 排除规则时,这条派生检查反过来 FAIL 了)。
|
|
273
|
+
if rel.endswith(junk_suffix) or any(k in rel for k in junk_parts) \
|
|
274
|
+
or any("%" in part for part in rel.split("/")):
|
|
275
|
+
continue
|
|
276
|
+
on_disk.append(rel)
|
|
277
|
+
packed_set = {f.replace("\\", "/") for f in files}
|
|
278
|
+
not_packed = [p for p in on_disk if p not in packed_set]
|
|
279
|
+
assert not not_packed, (
|
|
280
|
+
"这些文件在仓库里但没进包(package.json 的 files 漏了?):%s"
|
|
281
|
+
% ", ".join(not_packed[:8]))
|
|
282
|
+
return "%d 个文件 / %.0f KB;%d 个受管目录文件全部进包" % (
|
|
283
|
+
len(files), meta.get("size", 0) / 1024.0, len(on_disk))
|
|
216
284
|
|
|
217
285
|
|
|
218
286
|
# ---------------------------------------------------------------- 6 文档引用
|
|
@@ -283,8 +351,9 @@ def t_doc_refs():
|
|
|
283
351
|
def t_size():
|
|
284
352
|
big = []
|
|
285
353
|
for p in [p for p in ROOT.rglob("*") if p.is_file()
|
|
286
|
-
and
|
|
354
|
+
and not (set(p.parts) & SIZE_SKIP_DIRS)
|
|
287
355
|
and not str(p.relative_to(ROOT)).startswith("tmp_scratch_")
|
|
356
|
+
and p.suffix.lower() not in SIZE_SKIP_EXT
|
|
288
357
|
and p.suffix != ".png"]:
|
|
289
358
|
n = p.stat().st_size
|
|
290
359
|
if n > MAX_FILE_BYTES:
|
|
@@ -295,16 +364,74 @@ def t_size():
|
|
|
295
364
|
|
|
296
365
|
|
|
297
366
|
# ---------------------------------------------------------------- 8 兼容 Python 3.9(README/THIRD_PARTY 的承诺)
|
|
367
|
+
# 3.10+ 才有、但**语法上**能通过 3.9 解析的 API —— ast.parse(feature_version=(3,9)) 看不见它们。
|
|
368
|
+
# 这正是"门禁看着干净、其实没在查"的典型形态:zip(..., strict=True) 在 3.9 上是 TypeError
|
|
369
|
+
# (zip() takes no keyword arguments),而语法检查一路绿灯。
|
|
370
|
+
# 只查**确定**不兼容且容易误用的这几个;不要按 BinOp|BitOr 一律报错(numpy 位或会刷假失败)。
|
|
371
|
+
def _py39_runtime_violations(tree):
|
|
372
|
+
"""扫 3.10+ 运行期 API。返回 [(行号, 说明)]。"""
|
|
373
|
+
out = []
|
|
374
|
+
has_future_annotations = False
|
|
375
|
+
for node in tree.body:
|
|
376
|
+
if isinstance(node, ast.ImportFrom) and node.module == "__future__":
|
|
377
|
+
if any(a.name == "annotations" for a in node.names):
|
|
378
|
+
has_future_annotations = True
|
|
379
|
+
ann_nodes = set()
|
|
380
|
+
for node in ast.walk(tree):
|
|
381
|
+
# 收集"注解位置"的节点:只有这里的 X | Y 才是类型联合,函数体里的是 numpy 位或
|
|
382
|
+
for attr in ("annotation", "returns"):
|
|
383
|
+
sub = getattr(node, attr, None)
|
|
384
|
+
if sub is not None:
|
|
385
|
+
for x in ast.walk(sub):
|
|
386
|
+
ann_nodes.add(id(x))
|
|
387
|
+
for node in ast.walk(tree):
|
|
388
|
+
if isinstance(node, ast.Call):
|
|
389
|
+
fn = node.func
|
|
390
|
+
name = getattr(fn, "attr", None) or getattr(fn, "id", None)
|
|
391
|
+
if name == "zip":
|
|
392
|
+
for kw in node.keywords:
|
|
393
|
+
if kw.arg == "strict":
|
|
394
|
+
out.append((node.lineno, "zip(..., strict=...) 需要 Python 3.10+"))
|
|
395
|
+
if name == "bit_count":
|
|
396
|
+
out.append((node.lineno, "int.bit_count() 需要 Python 3.10+"))
|
|
397
|
+
if name == "dataclass":
|
|
398
|
+
for kw in node.keywords:
|
|
399
|
+
if kw.arg in ("slots", "kw_only"):
|
|
400
|
+
out.append((node.lineno,
|
|
401
|
+
"dataclass(slots=/kw_only=) 需要 Python 3.10+"))
|
|
402
|
+
elif isinstance(node, ast.Import):
|
|
403
|
+
for a in node.names:
|
|
404
|
+
if a.name.split(".")[0] == "tomllib":
|
|
405
|
+
out.append((node.lineno, "tomllib 需要 Python 3.11+"))
|
|
406
|
+
elif isinstance(node, ast.ImportFrom):
|
|
407
|
+
if (node.module or "").split(".")[0] == "tomllib":
|
|
408
|
+
out.append((node.lineno, "tomllib 需要 Python 3.11+"))
|
|
409
|
+
elif isinstance(node, ast.TryStar):
|
|
410
|
+
out.append((node.lineno, "except* 需要 Python 3.11+"))
|
|
411
|
+
elif isinstance(node, ast.Match):
|
|
412
|
+
out.append((node.lineno, "match/case 需要 Python 3.10+"))
|
|
413
|
+
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
414
|
+
if not has_future_annotations and id(node) in ann_nodes:
|
|
415
|
+
out.append((node.lineno,
|
|
416
|
+
"X | Y 类型注解需要 3.10+(用 Union[X, Y] 或加 "
|
|
417
|
+
"from __future__ import annotations)"))
|
|
418
|
+
return out
|
|
419
|
+
|
|
420
|
+
|
|
298
421
|
def t_py39():
|
|
299
422
|
bad = []
|
|
300
423
|
for p in _py_files():
|
|
301
424
|
src = p.read_text(encoding="utf-8")
|
|
302
425
|
try:
|
|
303
|
-
ast.parse(src, filename=str(p), feature_version=(3, 9))
|
|
426
|
+
tree = ast.parse(src, filename=str(p), feature_version=(3, 9))
|
|
304
427
|
except SyntaxError as e:
|
|
305
428
|
bad.append("%s:%s %s" % (p.name, e.lineno, e.msg))
|
|
306
|
-
|
|
307
|
-
|
|
429
|
+
continue
|
|
430
|
+
for lineno, why in _py39_runtime_violations(tree):
|
|
431
|
+
bad.append("%s:%s %s" % (p.name, lineno, why))
|
|
432
|
+
assert not bad, ";".join(bad[:6])
|
|
433
|
+
return ("%d 个 .py 通过 3.9 语法解析 + 3.10+ 运行期 API 扫描"
|
|
434
|
+
"(真 3.9 运行仍需实测)" % len(_py_files()))
|
|
308
435
|
|
|
309
436
|
|
|
310
437
|
|
|
@@ -410,6 +537,8 @@ def t_film_contract():
|
|
|
410
537
|
def main(argv=None) -> int:
|
|
411
538
|
ap = argparse.ArgumentParser(description="仓库级体检(不需要 ffmpeg / 字体 / 网络)")
|
|
412
539
|
ap.add_argument("-v", "--verbose", action="store_true", help="打印每一节的详细证据")
|
|
540
|
+
ap.add_argument("--strict", action="store_true",
|
|
541
|
+
help="SKIP 也算失败(本地缺 node/npm 时想确认\"真的全查了\"就用它)")
|
|
413
542
|
a = ap.parse_args(argv)
|
|
414
543
|
try:
|
|
415
544
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
@@ -433,16 +562,27 @@ def main(argv=None) -> int:
|
|
|
433
562
|
print("ci_check %s" % ROOT)
|
|
434
563
|
print("-" * 66)
|
|
435
564
|
for name, fn in checks:
|
|
436
|
-
|
|
437
|
-
for
|
|
438
|
-
|
|
439
|
-
|
|
565
|
+
check(name, fn)
|
|
566
|
+
for status, name, detail in _results:
|
|
567
|
+
# PASS 的 detail 只在 -v 下打印(它通常只是"N 个文件全部可编译");FAIL/SKIP 一律打印原因
|
|
568
|
+
print(" %s %-24s %s" % (status, name, detail if (status != "PASS" or a.verbose) else ""))
|
|
569
|
+
fails = [r for r in _results if r[0] == "FAIL"]
|
|
570
|
+
skips = [r for r in _results if r[0] == "SKIP"]
|
|
571
|
+
n_pass = len(_results) - len(fails) - len(skips)
|
|
440
572
|
print("-" * 66)
|
|
441
|
-
print("PASS %d / FAIL %d" % (
|
|
573
|
+
print("PASS %d / FAIL %d / SKIP %d" % (n_pass, len(fails), len(skips)))
|
|
442
574
|
if fails:
|
|
443
575
|
print("\n失败项:")
|
|
444
|
-
for
|
|
576
|
+
for _status, name, detail in fails:
|
|
577
|
+
print(" - %s:%s" % (name, detail))
|
|
578
|
+
if skips:
|
|
579
|
+
print("\n跳过项(**不等于通过**):")
|
|
580
|
+
for _status, name, detail in skips:
|
|
445
581
|
print(" - %s:%s" % (name, detail))
|
|
582
|
+
# 退出码口径:默认只把 FAIL 当失败(CI 里 node/npm 一定在,SKIP 不该出现);
|
|
583
|
+
# --strict 把 SKIP 也算失败——本地没装 node 的人想确认"真的全查了"就用它。
|
|
584
|
+
if a.strict and skips:
|
|
585
|
+
return 1
|
|
446
586
|
return 1 if fails else 0
|
|
447
587
|
|
|
448
588
|
|
package/tools/estimate.py
CHANGED
|
@@ -65,6 +65,14 @@ MUX_PER_SEC = 0.045 # 每片长秒(两遍 loudnorm 解码音
|
|
|
65
65
|
QA_BASE_SEC = (1.0, 3.0) # 固定开销:联系表 + 参考帧重渲染
|
|
66
66
|
QA_SEC_PER_SEC = (0.12, 0.25) # 随片长:帧级深检流式解码 + 响度曲线
|
|
67
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 逐帧比)
|
|
68
76
|
|
|
69
77
|
# 校准出处:换成你自己的机器前,请按 SKILL.md §12 重测并回填——这些数字决定预演准不准。
|
|
70
78
|
CALIBRATION = dict(
|
|
@@ -101,6 +109,10 @@ STAGE_LABELS = {
|
|
|
101
109
|
"frames": "逐帧渲染+编码",
|
|
102
110
|
"mux": "合成成片",
|
|
103
111
|
"qa": "质检+抽帧",
|
|
112
|
+
# v0.8:交付前的三道新门禁单独列(它们的成本不能藏进 qa 里)
|
|
113
|
+
"preflight": "渲染前预检",
|
|
114
|
+
"vmaf": "全片VMAF/SSIM",
|
|
115
|
+
"asr": "ASR回读",
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
|
|
@@ -209,6 +221,31 @@ def _fmt_span(lo, hi):
|
|
|
209
221
|
return "%.1f–%.1f s" % (float(lo), float(hi))
|
|
210
222
|
|
|
211
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
|
+
|
|
212
249
|
# ============================================================================
|
|
213
250
|
# 估算核心
|
|
214
251
|
# ============================================================================
|
|
@@ -305,18 +342,6 @@ def mux_seconds_pair(frames, jobs, total, script):
|
|
|
305
342
|
return base * 0.75, base * 1.35
|
|
306
343
|
|
|
307
344
|
|
|
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
345
|
def chunk_plan(frames, fps, jobs, explicit):
|
|
321
346
|
"""逐字复刻 render.py 的分块规则(改了那边记得同步这里)。
|
|
322
347
|
|
|
@@ -343,7 +368,6 @@ def disk_estimate(frames, total, n_lines, n_cues, crf, music_on):
|
|
|
343
368
|
factor = 2.0 ** ((17.0 - crf) / 6.0) # crf 每降 6 体积约翻倍(x264 经验值)
|
|
344
369
|
vlo = max(0.3, VIDEO_MBPS[0] * factor)
|
|
345
370
|
vhi = max(0.3, VIDEO_MBPS[1] * factor)
|
|
346
|
-
vid_lo = total * vlo * 1e6 / 8.0
|
|
347
371
|
vid_hi = total * vhi * 1e6 / 8.0
|
|
348
372
|
out_lo = total * (vlo + AUDIO_MBPS) * 1e6 / 8.0
|
|
349
373
|
out_hi = total * (vhi + AUDIO_MBPS) * 1e6 / 8.0
|
|
@@ -396,16 +420,39 @@ def build_report(script_path, script, args):
|
|
|
396
420
|
_warn("engine=%r 不认识(只能是 pil / web),按 pil 预演" % engine)
|
|
397
421
|
engine = "pil"
|
|
398
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))
|
|
399
443
|
tl, tl_path = read_timeline(args.work)
|
|
400
444
|
if tl is not None:
|
|
401
445
|
total = _num(tl.get("total"))
|
|
402
|
-
fps = int(_num(tl.get("fps"),
|
|
446
|
+
fps = int(_num(tl.get("fps"), _vp["fps"])) or _vp["fps"]
|
|
403
447
|
frames = int(_num(tl.get("frames"), round(total * fps)))
|
|
404
448
|
source = "timeline"
|
|
405
449
|
source_note = "真实帧数:%s" % tl_path
|
|
406
450
|
else:
|
|
407
451
|
total, _scenes = estimate_timeline(script)
|
|
408
|
-
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"])
|
|
409
456
|
frames = int(round(total * fps))
|
|
410
457
|
source = "script_estimate"
|
|
411
458
|
source_note = ("粗估(等效字数 ÷ %.1f 字/秒 + lead/tail + hold;没有 %s)"
|
|
@@ -463,6 +510,51 @@ def build_report(script_path, script, args):
|
|
|
463
510
|
mux_lo, mux_hi = mux_seconds_pair(frames, jobs_eff, total, script)
|
|
464
511
|
qa_lo = max(0.5, QA_BASE_SEC[0] + total * QA_SEC_PER_SEC[0])
|
|
465
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)")
|
|
466
558
|
|
|
467
559
|
stages = [
|
|
468
560
|
dict(stage="tts", lo=tts_lo, hi=tts_hi, measured=True,
|
|
@@ -496,8 +588,15 @@ def build_report(script_path, script, args):
|
|
|
496
588
|
dict(stage="mux", lo=mux_lo, hi=mux_hi, measured=True,
|
|
497
589
|
note=("-c:v copy + 两遍 loudnorm(实测 29 s 片 1.7–1.9 s,与帧数无关);"
|
|
498
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,渲一帧之前就失败)"),
|
|
499
593
|
dict(stage="qa", lo=qa_lo, hi=qa_hi, measured=True,
|
|
500
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)")),
|
|
501
600
|
]
|
|
502
601
|
total_lo = sum(s["lo"] for s in stages)
|
|
503
602
|
total_hi = sum(s["hi"] for s in stages)
|
|
@@ -508,9 +607,13 @@ def build_report(script_path, script, args):
|
|
|
508
607
|
disk = disk_estimate(frames, total, n, n_cues, crf, music_on)
|
|
509
608
|
|
|
510
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 段——预演与实现"逐字一致"的承诺当场失效。
|
|
511
614
|
explicit = int(_num(script.get("chunk_frames"), 0))
|
|
512
|
-
chunk, nseg = chunk_plan(frames, fps,
|
|
513
|
-
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,
|
|
514
617
|
segments=nseg, explicit=bool(explicit),
|
|
515
618
|
rule="chunk = chunk_frames 或 round(frames/jobs/fps/4)*4*fps,"
|
|
516
619
|
"再按 fps 取整、最小 2×fps")
|
|
@@ -521,7 +624,10 @@ def build_report(script_path, script, args):
|
|
|
521
624
|
"source": source,
|
|
522
625
|
"source_note": source_note,
|
|
523
626
|
"video": {"title": script.get("title"),
|
|
524
|
-
|
|
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,
|
|
525
631
|
"duration_s": round(total, 3), "engine": engine,
|
|
526
632
|
"crf": crf, "preset": preset, "bpm": mus.get("bpm", script.get("bpm")),
|
|
527
633
|
"style": script.get("style", None)},
|
|
@@ -547,7 +653,8 @@ def build_report(script_path, script, args):
|
|
|
547
653
|
"share": round(share, 3),
|
|
548
654
|
"advice": bottleneck_advice(worst["stage"], engine, jobs_eff)},
|
|
549
655
|
"assumptions": [
|
|
550
|
-
"
|
|
656
|
+
"内部构图恒为 1920x1080;交付尺寸/帧率可写 film.json 的 video 块"
|
|
657
|
+
"(v0.8,只允许 16:9 横屏且只能下采样;本次按 %d fps 估)" % fps,
|
|
551
658
|
"lead=%.2f / tail=%.2f / min_dur=%.1f(v0.5 body 基准;剧本写了就以剧本为准)"
|
|
552
659
|
% (DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR),
|
|
553
660
|
"tts 耗时按 %d 句 ÷ workers × %.1f–%.1f s(每句每 worker);实际受网络波动影响"
|
|
@@ -556,6 +663,10 @@ def build_report(script_path, script, args):
|
|
|
556
663
|
% (PARALLEL_EFF, CORES),
|
|
557
664
|
"mux 默认 -c:v copy,只跟片长有关;reencode_video=true 才按帧数重编",
|
|
558
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]),
|
|
559
670
|
"基准来源见 calibration 字段(机型/日期/样本);换机请重测后回填顶部常量",
|
|
560
671
|
],
|
|
561
672
|
"baseline": BASELINE_NOTE,
|