@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +951 -0
  2. package/README.md +51 -8
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/index.js +4 -1
  5. package/package.json +5 -3
  6. package/schemas/film.schema.json +162 -2
  7. package/skills/video-studio/SKILL.md +211 -11
  8. package/skills/video-studio/assets/golden_frames.json +65 -0
  9. package/skills/video-studio/assets/web/engine.js +63 -2
  10. package/skills/video-studio/assets/web/film.js +224 -101
  11. package/skills/video-studio/assets/web/index.html +4 -2
  12. package/skills/video-studio/assets/web/qa_text.js +36 -8
  13. package/skills/video-studio/references/audio-variation.md +4 -3
  14. package/skills/video-studio/references/audio.md +2 -2
  15. package/skills/video-studio/references/change-impact.md +1 -1
  16. package/skills/video-studio/references/quality-gates.md +25 -3
  17. package/skills/video-studio/references/style-packs.md +7 -3
  18. package/skills/video-studio/references/v08-quality.md +256 -0
  19. package/skills/video-studio/references/web-engine.md +19 -2
  20. package/skills/video-studio/references/word-timeline.md +147 -0
  21. package/skills/video-studio/requirements.txt +14 -0
  22. package/skills/video-studio/scripts/filmcheck.py +121 -0
  23. package/skills/video-studio/scripts/filmkit.py +521 -38
  24. package/skills/video-studio/scripts/gates.py +88 -4
  25. package/skills/video-studio/scripts/preflight.py +774 -0
  26. package/skills/video-studio/scripts/qa_frames.py +49 -8
  27. package/skills/video-studio/scripts/qaasr.py +1563 -0
  28. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  29. package/skills/video-studio/scripts/render.py +719 -32
  30. package/skills/video-studio/scripts/scorekit.py +16 -30
  31. package/skills/video-studio/scripts/selfcheck.py +274 -0
  32. package/skills/video-studio/scripts/sfxkit.py +130 -14
  33. package/skills/video-studio/scripts/stagewatch.py +19 -6
  34. package/skills/video-studio/scripts/stylekit.py +43 -12
  35. package/skills/video-studio/scripts/voicekit.py +15 -7
  36. package/skills/video-studio/scripts/webengine.py +1119 -72
  37. package/tools/ci_check.py +171 -31
  38. package/tools/estimate.py +130 -19
  39. 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
- 退出码:0 全过;1 有 FAIL。每一项都打印证据,不打"可能有问题"。
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
- _results: list[tuple[bool, str, str]] = []
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
- """跑一项检查;返回 (ok, name, detail)。任何异常都算 FAIL 并保留消息。"""
82
+ """跑一项检查;返回 PASS / FAIL / SKIP 三态,detail 保留证据或原因。"""
60
83
  try:
61
84
  detail = fn() or ""
62
- _results.append((True, name, str(detail)))
63
- return True
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((False, name, str(e)))
66
- return False
91
+ _results.append(("FAIL", name, str(e)))
92
+ return "FAIL"
67
93
  except Exception as e: # noqa: BLE001
68
- _results.append((False, name, "%s: %s" % (type(e).__name__, e)))
69
- return False
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
- return "%d 个 .json 全部可解析;version=%s 与 README 徽章一致" % (len(files), ver)
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
- must = ["index.js", "cordis.patch.yml", "icon.svg", "package.json", "README.md",
208
- "skills/video-studio/SKILL.md", "skills/video-studio/scripts/filmkit.py",
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
- return "%d 个文件 / %.0f KB" % (len(files), meta.get("size", 0) / 1024.0)
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 ".git" not in p.parts and "node_modules" not in p.parts
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
- assert not bad, ";".join(bad)
307
- return "%d 个 .py 通过 3.9 语法解析(运行期兼容仍需真 3.9 实测)" % len(_py_files())
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
- ok = check(name, fn)
437
- for ok, name, detail in _results:
438
- print(" %s %-24s %s" % ("PASS" if ok else "FAIL", name, detail if (ok or a.verbose) else ""))
439
- fails = [r for r in _results if not r[0]]
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" % (len(_results) - len(fails), len(fails)))
573
+ print("PASS %d / FAIL %d / SKIP %d" % (n_pass, len(fails), len(skips)))
442
574
  if fails:
443
575
  print("\n失败项:")
444
- for ok, name, detail in fails:
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"), FPS)) or 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 = 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, jobs_eff, explicit)
513
- chunk = dict(jobs=jobs_eff, jobs_requested=jobs_req, chunk_frames=chunk,
615
+ chunk, nseg = chunk_plan(frames, fps, jobs_req, explicit)
616
+ chunk = dict(jobs=jobs_req, jobs_requested=jobs_req, chunk_frames=chunk,
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
- "width": W, "height": H, "fps": fps, "frames": frames,
627
+ # v0.8:内部构图恒为 1080p,交付尺寸可以更小(_vp 从 film.json 的 video 块来)
628
+ "width": _vp["w"], "height": _vp["h"],
629
+ "out_width": _vp["out_w"], "out_height": _vp["out_h"],
630
+ "fps": fps, "frames": frames,
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
- "分辨率 1920x1080@%d 由 filmkit.W/H/FPS 写死,film.json 改不了" % FPS,
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,