@dsh-gis/dsh-video-studio-skill 0.7.2 → 0.9.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 (45) hide show
  1. package/CHANGELOG.md +1109 -0
  2. package/README.md +76 -10
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/docs/styles/sheet-8-styles.png +0 -0
  5. package/docs/styles/sheet-before-after.png +0 -0
  6. package/index.js +4 -1
  7. package/package.json +7 -3
  8. package/schemas/film.schema.json +329 -2
  9. package/skills/video-studio/SKILL.md +270 -13
  10. package/skills/video-studio/assets/golden_frames.json +65 -0
  11. package/skills/video-studio/assets/scenes.py +2162 -1190
  12. package/skills/video-studio/assets/web/engine.js +63 -2
  13. package/skills/video-studio/assets/web/film.js +224 -101
  14. package/skills/video-studio/assets/web/index.html +4 -2
  15. package/skills/video-studio/assets/web/qa_text.js +36 -8
  16. package/skills/video-studio/references/audio-variation.md +4 -3
  17. package/skills/video-studio/references/audio.md +2 -2
  18. package/skills/video-studio/references/change-impact.md +1 -1
  19. package/skills/video-studio/references/quality-gates.md +25 -3
  20. package/skills/video-studio/references/scene-grammar.md +306 -0
  21. package/skills/video-studio/references/style-packs.md +108 -3
  22. package/skills/video-studio/references/v08-quality.md +256 -0
  23. package/skills/video-studio/references/web-engine.md +19 -2
  24. package/skills/video-studio/references/word-timeline.md +147 -0
  25. package/skills/video-studio/requirements.txt +14 -0
  26. package/skills/video-studio/scripts/filmcheck.py +218 -0
  27. package/skills/video-studio/scripts/filmkit.py +521 -38
  28. package/skills/video-studio/scripts/gates.py +88 -4
  29. package/skills/video-studio/scripts/grammar.py +559 -0
  30. package/skills/video-studio/scripts/preflight.py +774 -0
  31. package/skills/video-studio/scripts/qa_frames.py +49 -8
  32. package/skills/video-studio/scripts/qaasr.py +1563 -0
  33. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  34. package/skills/video-studio/scripts/render.py +764 -32
  35. package/skills/video-studio/scripts/scorekit.py +16 -30
  36. package/skills/video-studio/scripts/selfcheck.py +483 -0
  37. package/skills/video-studio/scripts/sfxkit.py +130 -14
  38. package/skills/video-studio/scripts/stagewatch.py +19 -6
  39. package/skills/video-studio/scripts/stylekit.py +242 -12
  40. package/skills/video-studio/scripts/voicekit.py +15 -7
  41. package/skills/video-studio/scripts/webengine.py +1119 -72
  42. package/tools/ci_check.py +173 -31
  43. package/tools/estimate.py +130 -19
  44. package/tools/golden_frames.py +664 -0
  45. package/tools/silhouette_qa.py +910 -0
@@ -35,6 +35,7 @@ import importlib.util
35
35
  import json
36
36
  import os
37
37
  import shutil
38
+ import subprocess
38
39
  import sys
39
40
  import time
40
41
  from pathlib import Path
@@ -90,14 +91,22 @@ def _file_sha1(path):
90
91
 
91
92
 
92
93
  def _frames_plan_stamp(tl, script, scenes_path, chunk):
93
- """分块计划的指纹:时长 / scenes.py 内容 / 剧本快照任一变化,旧分块就画出错帧。
94
+ """分块计划的指纹:时长 / scenes.py 内容 / 剧本快照 / 时间轴内容任一变化,旧分块就画出错帧。
94
95
 
95
96
  断点续跑只看"分块文件在不在"是危险的:改一句台词 -> 时间轴变了 -> 旧分块
96
97
  仍然被跳过复用,最后拼出一部"画面和旁白对不上"的片子却不报错。
98
+
99
+ 为什么不能只哈希 total/frames/fps:字幕窗口(voice_start/voice_end)与词级断点
100
+ (words_abs)都在 tl["scenes"] 里,而它们可以在 total 分毫不动的情况下变化
101
+ ——逐句被 min_dur 夹住时,重新合成的 TTS 只改句内时长、不改段落总长。只比三个
102
+ 标量会让这种情况静默复用旧分块(字幕按上一版的词断行画)。代价是每家分块计划
103
+ 多一次 timeline.json 的 json.dumps(几十 KB,相对渲一帧可忽略)。
97
104
  """
98
105
  body = json.dumps(dict(script), sort_keys=True, ensure_ascii=False, default=str)
106
+ tl_body = json.dumps(tl, sort_keys=True, ensure_ascii=False, default=str)
99
107
  payload = dict(total=round(float(tl["total"]), 3), frames=int(tl["frames"]),
100
108
  fps=int(tl["fps"]), chunk=int(chunk),
109
+ timeline=hashlib.sha1(tl_body.encode("utf-8")).hexdigest()[:16],
101
110
  scenes=_file_sha1(scenes_path),
102
111
  script=hashlib.sha1(body.encode("utf-8")).hexdigest()[:16])
103
112
  return hashlib.sha1(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()[:16]
@@ -224,6 +233,53 @@ def load_script(p) -> dict:
224
233
  return s
225
234
 
226
235
 
236
+ def _to_gif(mp4, gif, fps=12, width=640):
237
+ """mp4 -> GIF(两遍调色板,避免"一屏噪点"的默认调色板)。
238
+
239
+ GIF 只用来"快速看一眼大概长什么样"(发给别人、贴到文档里),
240
+ 不是交付物:它没有声音、色彩也被量化。真正的交付仍是 mp4。
241
+ """
242
+ pal = gif + ".palette.png"
243
+ _run_ff = K._run if hasattr(K, "_run") else None
244
+ vf1 = "fps=%d,scale=%d:-1:flags=lanczos,palettegen=stats_mode=diff" % (fps, width)
245
+ vf2 = "fps=%d,scale=%d:-1:flags=lanczos,paletteuse=dither=bayer:bayer_scale=3" % (fps, width)
246
+ for args in ([K.FFMPEG, "-y", "-v", "error", "-i", str(mp4), "-vf", vf1, pal],
247
+ [K.FFMPEG, "-y", "-v", "error", "-i", str(mp4), "-i", pal,
248
+ "-lavfi", vf2, "-loop", "0", str(gif)]):
249
+ p = subprocess.run(args, capture_output=True)
250
+ if p.returncode != 0:
251
+ raise RuntimeError("GIF 生成失败: " + p.stderr.decode("utf-8", "replace")[:400])
252
+ try:
253
+ os.remove(pal)
254
+ except OSError:
255
+ pass
256
+ return gif
257
+
258
+
259
+ def _atomic_json_retry(path, obj, tries=3, sleep_s=0.1):
260
+ """原子写 JSON + 有限退避(v0.8)。
261
+
262
+ Windows 上文件被杀毒/预览进程短暂占用时,一次写入失败会让**整片**跑挂。
263
+ 只对"可重试的写状态"退避,不对内容生成盲目重试(那会掩盖真错误)。
264
+ """
265
+ last = None
266
+ for i in range(max(1, int(tries))):
267
+ try:
268
+ d = os.path.dirname(os.path.abspath(str(path)))
269
+ if d:
270
+ os.makedirs(d, exist_ok=True)
271
+ tmp = str(path) + ".tmp"
272
+ with open(tmp, "w", encoding="utf-8") as fh:
273
+ json.dump(obj, fh, ensure_ascii=False, indent=1)
274
+ os.replace(tmp, str(path))
275
+ return True
276
+ except OSError as e: # noqa: BLE001
277
+ last = e
278
+ time.sleep(sleep_s * (i + 1))
279
+ print(" [WARN] 写 %s 失败(重试 %d 次):%s" % (path, tries, last))
280
+ return False
281
+
282
+
227
283
  def _need(cond, msg):
228
284
  if not cond:
229
285
  raise SystemExit("[前置产物缺失] " + msg)
@@ -258,6 +314,18 @@ def resolve_style(script, work=None, verbose=True):
258
314
  bad = ST.theme_lint(plan.get("theme") or ST.THEMES[plan["theme_id"]])
259
315
  if bad:
260
316
  raise SystemExit("[主题体检失败] %s:%s" % (plan["theme_id"], ";".join(bad)))
317
+ # v0.9:结构签名(视觉语言包)随计划一起解析,并同样在 --stage style 就体检。
318
+ # 为什么不能等渲染:装饰母题名写错/转场时长越界,都要等渲到第 300 帧才发现,
319
+ # 而这两类错误恰恰会让整片观感崩掉(转场 1 秒 = 每段都在"糊过去")。
320
+ try:
321
+ plan["language"] = ST.visual_language(plan["style"])
322
+ vbad = ST.visual_lint(plan["style"])
323
+ except AttributeError:
324
+ # 老 stylekit(没有 VISUAL_LANGUAGES)-> 不阻塞渲染,scenes.py 会退到默认语言
325
+ plan["language"] = None
326
+ vbad = []
327
+ if vbad:
328
+ raise SystemExit("[视觉语言体检失败] %s:%s" % (plan["style"], ";".join(vbad)))
261
329
  script["_style"] = plan
262
330
  if work and verbose:
263
331
  path = os.path.join(work, "style.json")
@@ -277,6 +345,14 @@ def _theme_for(script):
277
345
  plan = resolve_style(script)
278
346
  if plan.get("legacy"):
279
347
  return None
348
+ # plan["theme"] 是 stylekit 合并过 film.json 的 visual.theme_overrides 之后的最终主题,
349
+ # resolve_style 的对比度体检(theme_lint)用的就是它。这里必须用同一份:否则
350
+ # scenes 画背景吃 overrides、字幕底板/字色仍用基础主题——字幕压在同色背景上会看不清,
351
+ # 而体检还照报通过("门禁看着干净、其实没在查")。老 stylekit / 老计划没有 theme
352
+ # 字段时回落到基础主题,行为与本行改动前一致。
353
+ th = plan.get("theme")
354
+ if isinstance(th, dict):
355
+ return th
280
356
  return _stylekit().THEMES[plan["theme_id"]]
281
357
 
282
358
 
@@ -289,6 +365,18 @@ def _print_style(plan):
289
365
  print(" 主题: %s 版式: %s" % (plan["theme_id"],
290
366
  ",".join(sorted(set(plan["chapter_layout"].values())))
291
367
  or plan["layout_default"]))
368
+ # v0.9:视觉语言(结构签名)也要能在这里看到 —— 用户判断"这套风格是不是只是换色"
369
+ # 的第一现场就是 --stage style 的打印,藏起来等于让他去读 style.json。
370
+ vl = plan.get("language") if isinstance(plan.get("language"), dict) else None
371
+ if vl:
372
+ mf = vl.get("motif") or {}
373
+ tr = vl.get("transition") or {}
374
+ ty = vl.get("typography") or {}
375
+ bi = vl.get("bias") or {}
376
+ print(" 视觉语言: 母题=%s 转场=%s(%.2fs) 字距=%.1f 编号=%s 重心=(%+.0f,%+.0f)"
377
+ % (mf.get("kind"), tr.get("kind"), float(tr.get("dur") or 0.0),
378
+ float(ty.get("tracking") or 0.0), ty.get("number"),
379
+ float(bi.get("x") or 0.0), float(bi.get("y") or 0.0)))
292
380
  print(" 配乐: %s BPM %g(%s) 调性 %s(%s) mood %s"
293
381
  % (m["style"], m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
294
382
  if v["map"]:
@@ -338,6 +426,7 @@ def st_tts(script, work, force=False):
338
426
  ensure_ascii=False, indent=1)
339
427
  if force:
340
428
  shutil.rmtree(os.path.join(work, "tts"), ignore_errors=True)
429
+ want_words = bool(script.get("words", True))
341
430
  done = K.tts_batch(lines, work,
342
431
  voice=script.get("voice", "zh-CN-YunxiNeural"),
343
432
  rate=script.get("rate", "+0%"),
@@ -348,14 +437,50 @@ def st_tts(script, work, force=False):
348
437
  [{"voice": r["voice"], "rate": r["rate"], "pitch": r["pitch"],
349
438
  "volume": r.get("volume", "+0%"),
350
439
  "persona": r.get("persona"), "tone": r.get("tone")} for r in vp],
351
- trim_db=float(script.get("trim_db", -42.0)))
440
+ trim_db=float(script.get("trim_db", -42.0)),
441
+ words=want_words,
442
+ backend=str((script.get("tts") or {}).get("backend") or "edge"))
352
443
  json.dump(done, open(os.path.join(work, "tts.json"), "w", encoding="utf-8"),
353
444
  ensure_ascii=False, indent=1)
354
445
  total = sum(float(x["dur"]) for x in done)
355
446
  print(" 语音总时长 %.2fs / %d 句" % (total, len(done)))
447
+ _write_words(work, done, want_words)
356
448
  return done
357
449
 
358
450
 
451
+ def _write_words(work, done, want_words):
452
+ """把逐句词级时间戳汇总成 work/words.json(v0.8)。
453
+
454
+ 结构:{schema, enabled, lines:{<id>:{dur, n, covered, tail, mono, words:[...]}}, stats}
455
+ 门禁与字幕都从这里读,不再各自去翻 *.mp3.json(单一事实来源)。
456
+ """
457
+ if not want_words:
458
+ print(" 词级时间戳: 未启用(film.json 里 words=false)")
459
+ return None
460
+ lines = {}
461
+ n_words = 0
462
+ n_bad = []
463
+ for row in done or []:
464
+ ws = row.get("words") or []
465
+ summ = K.words_summary(ws, row.get("dur"))
466
+ lines[str(row["id"])] = dict(dur=round(float(row.get("dur") or 0.0), 4),
467
+ words=ws, **{k: v for k, v in summ.items() if k != "n"},
468
+ n=summ["n"])
469
+ n_words += len(ws)
470
+ if not ws:
471
+ n_bad.append(row["id"])
472
+ payload = dict(schema=1, enabled=True, lines=lines,
473
+ stats=dict(lines=len(lines), words=n_words, empty=n_bad))
474
+ p = os.path.join(work, "words.json")
475
+ json.dump(payload, open(p, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
476
+ if n_bad:
477
+ print(" [WARN] %d 句拿不到词级事件(%s);这些句的词级门禁会记 skipped"
478
+ % (len(n_bad), "、".join(n_bad[:6])))
479
+ else:
480
+ print(" 词级时间戳: %d 句 / %d 词 -> %s" % (len(lines), n_words, p))
481
+ return payload
482
+
483
+
359
484
  # ---------------------------------------------------------------- 排版
360
485
  def st_layout(script, work, lines=None):
361
486
  lines = lines or json.load(open(os.path.join(work, "tts.json"), encoding="utf-8"))
@@ -378,6 +503,7 @@ def st_layout(script, work, lines=None):
378
503
  for lid, v in tail_extra.items():
379
504
  if lid in tail_map:
380
505
  tail_map[lid] = float(v)
506
+ vp = K.video_plan(script) # 画幅/帧率:非法配置在这里就报错,不拖到渲帧
381
507
  tl = K.build_timeline(lines,
382
508
  lead=script.get("lead", pace["lead"]),
383
509
  tail=script.get("tail", pace["tail"]),
@@ -385,15 +511,51 @@ def st_layout(script, work, lines=None):
385
511
  tail_map=tail_map or script.get("tail_map"),
386
512
  lead_map=lead_map,
387
513
  hold=script.get("hold", pace["hold"]),
388
- max_dur=script.get("max_dur"))
514
+ max_dur=script.get("max_dur"),
515
+ fps=vp["fps"])
516
+ tl["video"] = vp
517
+ print(" 画幅: %d×%d @ %d fps(内部构图 %d×%d%s)"
518
+ % (vp["out_w"], vp["out_h"], vp["fps"], vp["w"], vp["h"],
519
+ ",编码时 lanczos 缩放" if vp["scale"] else ""))
389
520
  tl["title"] = script["title"]
390
521
  tl["voice"] = script.get("voice", "zh-CN-YunxiNeural")
522
+ # v0.8:词级时间戳(句子时长仍是权威;词只用于字幕断行/锚点/门禁)
523
+ tl["words"] = bool(script.get("words", True))
524
+ wp = os.path.join(work, "words.json")
525
+ if tl["words"] and os.path.exists(wp):
526
+ try:
527
+ wj = json.load(open(wp, encoding="utf-8"))
528
+ tl["word_stats"] = wj.get("stats")
529
+ except Exception as e: # noqa: BLE001
530
+ print(" [WARN] words.json 读不了,词级能力本次不可用:%s" % e)
531
+ tl["words"] = False
391
532
  tl["crf"] = script.get("crf", 18)
392
533
  tl["preset"] = script.get("preset", "medium")
393
534
  tl["style"] = {"style": plan["style"], "theme_id": plan["theme_id"],
394
535
  "legacy": plan["legacy"],
395
536
  "chapter_layout": plan["chapter_layout"],
396
537
  "item_stagger": pace["item_stagger"]}
538
+ # ---- v0.9 场景语法:内容 -> 构图选型(落 timeline.scenes[] 与 work/storyboard.json) ----
539
+ # legacy 不注入:v0.4 的三个老绘制器只读 kind,注入新字段等于给基线加噪声。
540
+ if not plan["legacy"]:
541
+ try:
542
+ import grammar as G
543
+ except Exception as e: # noqa: BLE001
544
+ G = None
545
+ print(" [WARN] 没有 grammar.py,本次全部按 kind 走兼容构图:%s" % e)
546
+ if G is not None:
547
+ sb = G.plan_scenes(tl["scenes"], script, plan)
548
+ try:
549
+ json.dump(sb, open(os.path.join(work, "storyboard.json"), "w", encoding="utf-8"),
550
+ ensure_ascii=False, indent=1)
551
+ except Exception as e: # noqa: BLE001
552
+ print(" [WARN] storyboard.json 落盘失败:%s" % e)
553
+ print(" 分镜: %s" % G.describe(sb))
554
+ for r in sb["scenes"]:
555
+ print(" %-6s %-8s -> %-9s v%d d%d %s"
556
+ % (r["id"], r["kind"], r["type"], r["variant"], r["decor"],
557
+ ";".join(r["reason"][:2]) or "-"))
558
+ tl["storyboard"] = {"type_counts": sb["type_counts"]}
397
559
  json.dump(tl, open(os.path.join(work, "timeline.json"), "w", encoding="utf-8"),
398
560
  ensure_ascii=False, indent=1)
399
561
  n_srt = K.write_srt(tl["scenes"], os.path.join(work, "sub.srt"))
@@ -514,7 +676,19 @@ def _st_cues_web(script, work):
514
676
  """调用 webengine.export_cues 导出页面提示点(engine=web 时)。"""
515
677
  we = _opt_module("webengine", "浏览器逐帧渲染需要 webengine.py;只用 PIL 路线可以不改 engine")
516
678
  entry = _web_entry(script)
517
- data = we.export_cues(entry)
679
+ tl = {}
680
+ try:
681
+ tp = os.path.join(str(work), "timeline.json")
682
+ if os.path.exists(tp):
683
+ tl = json.load(open(tp, encoding="utf-8"))
684
+ except Exception: # noqa: BLE001
685
+ tl = {}
686
+ style = _web_style_payload(we, script, tl)
687
+ vp = (tl.get("video") or {})
688
+ data = we.export_cues(entry, style=style,
689
+ width=int(vp.get("out_w") or K.W),
690
+ height=int(vp.get("out_h") or K.H),
691
+ fps=int(tl.get("fps") or K.FPS))
518
692
  return data or {}
519
693
 
520
694
 
@@ -847,12 +1021,64 @@ def st_audio(tl, work, script):
847
1021
  return out
848
1022
 
849
1023
 
1024
+ # ---------------------------------------------------------------- 预检闸门
1025
+ def _run_preflight(script, work, engine, tl=None, skip=False):
1026
+ """渲染前资源闸门(v0.8):缺字形 / 坏音频 / 缺字体,在渲第一帧之前就 fail。
1027
+
1028
+ 为什么值得单独立一道闸:这类问题以前都是"片渲完、QA 才报"——870 帧白跑,
1029
+ 而且 reports 里只有一句"缺字形",说不清是哪个字段哪句台词。
1030
+ 三档语义:没装 preflight.py -> skipped(写明原因);有 error -> fail;
1031
+ 只有 warning -> pass(warning 仍进账本,不静默)。
1032
+ """
1033
+ lg = _gate()
1034
+ pf = _try_module("preflight")
1035
+ if skip:
1036
+ if lg is not None:
1037
+ lg.bypass("渲染前资源预检(--no-preflight)", "本条不算通过,只记录")
1038
+ print(" [preflight] --no-preflight:跳过资源预检(QC 报告里会留痕)")
1039
+ return True
1040
+ if pf is None:
1041
+ if lg is not None:
1042
+ lg.skip("G0", "渲染前资源预检", "没有 preflight.py(老版本工作目录,拷一份进来才能查)")
1043
+ print(" [preflight] 没有 preflight.py,跳过资源预检(会记 skipped)")
1044
+ return True
1045
+ try:
1046
+ res = pf.check(script, work=work, engine=engine, timeline=tl)
1047
+ except Exception as e: # noqa: BLE001
1048
+ if lg is not None:
1049
+ lg.fail("G0", "渲染前资源预检", "预检本身报错:%s" % e)
1050
+ print(" [FAIL][preflight] 预检自身出错:", e)
1051
+ return False
1052
+ for w in (res.get("warnings") or [])[:6]:
1053
+ print(" [WARN][preflight] %s" % (w.get("msg") if isinstance(w, dict) else w))
1054
+ errs = res.get("errors") or []
1055
+ st = (res.get("checks") or {})
1056
+ detail = " ".join("%s=%s" % (k, v.get("status")) for k, v in st.items()) or "-"
1057
+ if errs:
1058
+ if lg is not None:
1059
+ lg.fail("G0", "渲染前资源预检", "%d 处问题:%s" % (
1060
+ len(errs), ";".join((e.get("msg") if isinstance(e, dict) else str(e))
1061
+ for e in errs[:3])))
1062
+ print(" [FAIL][preflight] %d 处问题(下面是前几条):" % len(errs))
1063
+ for e in errs[:8]:
1064
+ msg = e.get("msg") if isinstance(e, dict) else str(e)
1065
+ where = (e.get("where") if isinstance(e, dict) else "") or ""
1066
+ print(" - %s%s" % ((where + ":") if where else "", msg))
1067
+ return False
1068
+ if lg is not None:
1069
+ lg.ok("G0", "渲染前资源预检", detail)
1070
+ print(" [preflight] %s" % detail)
1071
+ return True
1072
+
1073
+
850
1074
  # ---------------------------------------------------------------- 渲染
851
1075
  def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True):
852
1076
  """渲染 [f0, f1) 帧区间到 out_path。纯函数:同一帧号永远画出同一张图。"""
853
1077
  scenes = tl["scenes"]
854
1078
  total = tl["total"]
855
- sink = K.FrameSink(out_path, crf=crf, preset=preset)
1079
+ _vp = K.video_plan(script)
1080
+ sink = K.FrameSink(out_path, fps=float(tl["fps"]), crf=crf, preset=preset,
1081
+ out_w=_vp["out_w"], out_h=_vp["out_h"])
856
1082
  idx = 0
857
1083
  t_start = time.time()
858
1084
  last_rep = 0
@@ -868,8 +1094,9 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
868
1094
  th = _theme_for(script) if _THEME_CACHE.get("k") != id(script) else _THEME_CACHE.get("th")
869
1095
  if id(script) != _THEME_CACHE.get("k"):
870
1096
  _THEME_CACHE.update(k=id(script), th=th)
871
- frame = K.draw_subtitle(frame, K.sub_at(scenes, t, script.get("sub_max_hold")),
872
- theme=th)
1097
+ _hold = script.get("sub_max_hold")
1098
+ frame = K.draw_subtitle(frame, K.sub_at(scenes, t, _hold), theme=th,
1099
+ words=K.sub_words_at(scenes, t, _hold))
873
1100
  fade = 1.0
874
1101
  if t < 0.5:
875
1102
  fade *= K.ease_out(t / 0.5)
@@ -1071,19 +1298,55 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
1071
1298
  return silent
1072
1299
 
1073
1300
 
1301
+ def _web_style_payload(we, script, tl=None):
1302
+ """把风格包解析成 webengine 能吃的 __STYLE__ 载荷(v0.8)。
1303
+
1304
+ style=None(legacy 项目 / 解析失败)时返回 None —— webengine 收到 None 连
1305
+ add_init_script 都不调,页面行为与 v0.7 逐字节一致(这是兼容性硬要求)。
1306
+ """
1307
+ try:
1308
+ style_plan = resolve_style(script, verbose=False)
1309
+ except Exception as e: # noqa: BLE001
1310
+ print(" [WARN] web 风格包解析失败,页面用内置配色:%s" % e)
1311
+ return None
1312
+ if not style_plan or style_plan.get("legacy"):
1313
+ return None
1314
+ vp = (tl or {}).get("video") or {}
1315
+ try:
1316
+ return we.normalize_style(style_plan,
1317
+ width=int(vp.get("out_w") or K.W),
1318
+ height=int(vp.get("out_h") or K.H),
1319
+ fps=int((tl or {}).get("fps") or K.FPS))
1320
+ except Exception as e: # noqa: BLE001
1321
+ # 载荷不合法要**说出来**:静默回落会让"换了风格但页面没变"变成一个谜。
1322
+ print(" [WARN] web 风格载荷不可用(%s),页面用内置配色" % e)
1323
+ return None
1324
+
1325
+
1074
1326
  def _st_frames_web(tl, work, script, jobs=1, resume=True):
1075
1327
  """浏览器逐帧渲染:交给 webengine.render_full(分块并行 + 断点续跑 + 缺口检测)。
1076
1328
 
1077
1329
  页面的 window.DURATION / window.FPS 是切块与校验的唯一真值来源:
1078
1330
  和 timeline.json 对不上就直接报错(十有八九是改了剧本没重新 layout)。
1331
+ v0.8:把风格包与画幅/帧率一起注入页面(window.__STYLE__),页面不再是深蓝橙写死。
1079
1332
  """
1080
1333
  we = _opt_module("webengine",
1081
1334
  "浏览器渲染需要 webengine.py(本技能 scripts/ 里自带;还需要 pip install playwright)")
1082
1335
  entry = _web_entry(script)
1083
1336
  silent = os.path.join(work, "silent.mp4")
1084
1337
  web = script.get("web") or {}
1085
-
1086
- page_dur, page_fps = we.page_meta(entry)
1338
+ style = _web_style_payload(we, script, tl)
1339
+ vp = tl.get("video") or {}
1340
+ canvas_w = int(vp.get("out_w") or K.W)
1341
+ canvas_h = int(vp.get("out_h") or K.H)
1342
+ if style is not None:
1343
+ print(" 风格注入: %s(%dx%d @ %s fps)"
1344
+ % (style.get("style") or style.get("theme_id") or "custom",
1345
+ canvas_w, canvas_h, tl.get("fps")))
1346
+
1347
+ page_dur, page_fps = we.page_meta(entry, style=style,
1348
+ width=canvas_w, height=canvas_h,
1349
+ fps=int(tl["fps"]))
1087
1350
  if abs(float(page_fps) - float(tl["fps"])) > 1e-6:
1088
1351
  raise RuntimeError(
1089
1352
  "页面 FPS=%s 与 timeline fps=%s 不一致。浏览器逐帧渲染的帧号就是 f/FPS,"
@@ -1103,7 +1366,9 @@ def _st_frames_web(tl, work, script, jobs=1, resume=True):
1103
1366
  chunk_s=float(web.get("chunk_s", 30.0)),
1104
1367
  resume=bool(resume),
1105
1368
  crf=int(script.get("crf", 17)),
1106
- preset=str(script.get("preset", "slow")))
1369
+ preset=str(script.get("preset", "slow")),
1370
+ style=style, width=canvas_w, height=canvas_h,
1371
+ fps=int(tl["fps"]))
1107
1372
  got = K.media_dur(str(out) if out else silent)
1108
1373
  want = float(tl["frames"]) / float(tl["fps"])
1109
1374
  if abs(got - want) > 0.5:
@@ -1134,7 +1399,12 @@ def _qa_gates(lg, rep, deep, tl, work):
1134
1399
  v = next((s for s in streams if s.get("codec_type") == "video"), {})
1135
1400
  a = next((s for s in streams if s.get("codec_type") == "audio"), None)
1136
1401
  fmt = pr.get("format") or {}
1137
- W, H, FPS = K.W, K.H, K.FPS
1402
+ # v0.8:容器门禁比对的是**交付规格**(timeline 里记的 video 块),不是引擎内部常量。
1403
+ # 老 timeline(升级前生成的)没有 video 字段 -> 回落到 1920x1080,行为不变。
1404
+ _vp = tl.get("video") or {}
1405
+ W = int(_vp.get("out_w") or K.W)
1406
+ H = int(_vp.get("out_h") or K.H)
1407
+ FPS = float(_vp.get("fps") or tl.get("fps") or K.FPS)
1138
1408
 
1139
1409
  # ---- G4 容器:分辨率 / 帧率 / 音轨 / 时长 ----
1140
1410
  try:
@@ -1194,6 +1464,9 @@ def _qa_gates(lg, rep, deep, tl, work):
1194
1464
  else:
1195
1465
  lg.ok("G8", "首尾之外无长黑帧", "0 段")
1196
1466
 
1467
+ # ---- G10 词级时间戳(v0.8):覆盖率 / 单调 / 末词偏差 ----
1468
+ _word_gate(lg, tl, work)
1469
+
1197
1470
  # ---- G9 帧级深检(闪烁 / 空窗 / 音画同步)----
1198
1471
  if deep is None:
1199
1472
  lg.skip("G9", "帧级深检(闪烁/空窗/同步)", "没有 qa_frames.py 或深检失败")
@@ -1213,7 +1486,218 @@ def _qa_gates(lg, rep, deep, tl, work):
1213
1486
  (lg.ok if ok9 else lg.fail)("G9", "帧级深检(闪烁/空窗/同步超差)", detail9)
1214
1487
 
1215
1488
 
1216
- def st_qa(film, tl, work, scenes_mod=None, script=None):
1489
+ WORD_TAIL_MAX = 0.40 # 末词终点到句尾静音的上限(实测模板 0.07–0.12 s)
1490
+ WORD_START_MAX = 0.20 # 句首到首词的上限(裁剪把开头静音切掉后通常 ≤ 0.05 s)
1491
+ WORD_COVER_MIN = 0.45 # 词时长之和 / 句实测时长:中文有词间空隙,0.45 是"明显少了"的线
1492
+
1493
+
1494
+ def _word_gate(lg, tl, work):
1495
+ """词级时间戳门禁(v0.8)。
1496
+
1497
+ 为什么值得单独一道闸:词级数据是"字幕按词折行 / cue 词锚点"的依据,
1498
+ 但它来自一个**逆向接口**(edge-tts)。服务端一改,最可能的表现是"仍返回音频、
1499
+ 但词事件为空或整体偏移"——不查就永远发现不了,而画面上只表现为字幕断行变丑。
1500
+ 三档语义:没有启用 / 没有 words.json -> skipped(写明原因),不许冒充 pass。
1501
+ """
1502
+ wp = os.path.join(str(work), "words.json")
1503
+ if not tl.get("words"):
1504
+ lg.skip("G10", "词级时间戳(字幕断行/词锚点依据)",
1505
+ "本片未启用(film.json words=false 或 words.json 缺失)")
1506
+ return
1507
+ if not os.path.exists(wp):
1508
+ lg.skip("G10", "词级时间戳(字幕断行/词锚点依据)", "没有 words.json(跑 --stage tts 生成)")
1509
+ return
1510
+ try:
1511
+ wj = json.load(open(wp, encoding="utf-8"))
1512
+ except Exception as e: # noqa: BLE001
1513
+ lg.fail("G10", "词级时间戳(字幕断行/词锚点依据)", "words.json 读不了:%s" % e)
1514
+ return
1515
+ lines = wj.get("lines") or {}
1516
+ empty = [lid for lid, r in lines.items() if not r.get("n")]
1517
+ n_words = sum(int(r.get("n") or 0) for r in lines.values())
1518
+ if not lines or n_words == 0:
1519
+ lg.skip("G10", "词级时间戳(字幕断行/词锚点依据)",
1520
+ "词事件为空(edge-tts 只回了音频;本次降级为等分/整句字幕)")
1521
+ return
1522
+ bad = []
1523
+ if empty:
1524
+ bad.append("%d 句没有词事件(%s)" % (len(empty), "、".join(empty[:4])))
1525
+ mono_bad = [lid for lid, r in lines.items() if not r.get("mono", True)]
1526
+ if mono_bad:
1527
+ bad.append("词偏移非单调:%s" % "、".join(mono_bad[:4]))
1528
+ tail_bad = [(lid, float(r.get("tail") or 0.0)) for lid, r in lines.items()
1529
+ if r.get("n") and float(r.get("tail") or 0.0) > WORD_TAIL_MAX]
1530
+ if tail_bad:
1531
+ bad.append("末词终点离句尾过远(>%.2fs):%s"
1532
+ % (WORD_TAIL_MAX, "、".join("%s %.2fs" % x for x in tail_bad[:3])))
1533
+ start_bad = [(lid, float(r.get("start") or 0.0)) for lid, r in lines.items()
1534
+ if r.get("n") and float(r.get("start") or 0.0) > WORD_START_MAX]
1535
+ if start_bad:
1536
+ bad.append("首词起点离句首太远(>%.2fs):%s"
1537
+ % (WORD_START_MAX, "、".join("%s %.2fs" % x for x in start_bad[:3])))
1538
+ cover_bad = []
1539
+ for lid, r in lines.items():
1540
+ dur = float(r.get("dur") or 0.0)
1541
+ if not r.get("n") or dur <= 0:
1542
+ continue
1543
+ cov = float(r.get("covered") or 0.0) / dur
1544
+ if cov < WORD_COVER_MIN:
1545
+ cover_bad.append((lid, cov))
1546
+ if cover_bad:
1547
+ bad.append("词覆盖率过低(<%.0f%%):%s"
1548
+ % (WORD_COVER_MIN * 100,
1549
+ "、".join("%s %.0f%%" % (l, c * 100) for l, c in cover_bad[:3])))
1550
+ tail_max = max([float(r.get("tail") or 0.0) for r in lines.values()] or [0.0])
1551
+ detail = "%d 句 / %d 词 · 末词偏差最大 %.2fs" % (len(lines), n_words, tail_max)
1552
+ if bad:
1553
+ lg.fail("G10", "词级时间戳(字幕断行/词锚点依据)", detail + ";" + ";".join(bad))
1554
+ else:
1555
+ lg.ok("G10", "词级时间戳(字幕断行/词锚点依据)", detail)
1556
+
1557
+
1558
+ # ⚠ 阈值**只有一套**:权威定义在 qavmaf.py(VMAF_PASS=92 / VMAF_WARN=96,SSIM 仅诊断)。
1559
+ # 这里刻意不设第二套常量——本仓库的规矩是"同一事实只能有一个权威值"(CHANGELOG v0.4 D 节)。
1560
+ # 真实标定(qavmaf 作者实测,参考帧含字幕与片头片尾淡入淡出):
1561
+ # 模板片 crf17 VMAF 97.24(pass) / crf30 95.74(pass+warning) / 二次编码坏片 29.45(fail)
1562
+ # 想按片收紧或放宽就写 film.json 的 qa.vmaf.pass / qa.vmaf.warn,会透传给 qavmaf。
1563
+ def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
1564
+ """全片 VMAF/SSIM 门禁(v0.8):参考帧由场景函数实时渲染,与成片逐帧比较。
1565
+
1566
+ 为什么值得全片跑(用户明确要求"全片 + 阻断"):锐度比只在 4 个时刻采样,
1567
+ 能发现"整体发虚",但发现不了"某 30 秒里字幕糊了 / 某段梯度出现色带"。
1568
+ VMAF 是唯一能覆盖整条时间轴的客观指标。
1569
+
1570
+ 三档语义:
1571
+ * 没有 qavmaf.py / 没有 scenes.py(web 引擎)/ 显式 mode=off -> skipped,写明原因;
1572
+ * 分数低于 pass 阈值 -> fail(阻断交付);
1573
+ * 介于 pass 与 warn 之间 -> pass + warning(能出片,但建议降 CRF)。
1574
+ """
1575
+ qv = _try_module("qavmaf")
1576
+ if mode == "off":
1577
+ lg.skip("G11", "全片 VMAF / SSIM 画质", "本次显式关闭(--vmaf off 或 qa.vmaf.mode=off)")
1578
+ return None
1579
+ if qv is None:
1580
+ lg.skip("G11", "全片 VMAF / SSIM 画质", "没有 qavmaf.py(拷一份进来才能做全片画质门禁)")
1581
+ return None
1582
+ if scenes_mod is None:
1583
+ lg.skip("G11", "全片 VMAF / SSIM 画质",
1584
+ "web 引擎/缺 scenes.py:没有可重渲的参考帧(VMAF 参考帧必须来自场景函数)")
1585
+ return None
1586
+ cfg = ((script or {}).get("qa") or {}).get("vmaf") if isinstance((script or {}).get("qa"), dict) else {}
1587
+ cfg = cfg if isinstance(cfg, dict) else {}
1588
+ m = str(mode or cfg.get("mode") or "full")
1589
+ kw = {}
1590
+ for key, ckey in (("vmaf_pass", "pass"), ("vmaf_warn", "warn"),
1591
+ ("ssim_pass", "ssim_pass"), ("ssim_warn", "ssim_warn")):
1592
+ if cfg.get(ckey) is not None:
1593
+ kw[key] = float(cfg[ckey])
1594
+ # 抽样步长:qavmaf.report 的正式参数名是 sample_step(schema 里也是这个名字)。
1595
+ # 不传的话 mode=sample 永远按 qavmaf 内部默认步长 5 抽样——用户写了 sample_step
1596
+ # 也毫无效果("配置存在但没人读"正是最容易被忽略的那类静默缺陷)。
1597
+ if cfg.get("sample_step") is not None:
1598
+ kw["sample_step"] = max(1, int(cfg["sample_step"]))
1599
+ if cfg.get("ssim") and isinstance(cfg["ssim"], dict):
1600
+ for key, ckey in (("ssim_pass", "pass"), ("ssim_warn", "warn")):
1601
+ if cfg["ssim"].get(ckey) is not None:
1602
+ kw[key] = float(cfg["ssim"][ckey])
1603
+ print(" [vmaf] 模式=%s(参考帧=场景函数实时渲染,不是成片抽帧)" % m)
1604
+ try:
1605
+ res = qv.report(film, tl, scenes_mod, script, mode=m,
1606
+ out_json=os.path.join(str(work), "qa", "vmaf.json"), **kw)
1607
+ except Exception as e: # noqa: BLE001
1608
+ lg.fail("G11", "全片 VMAF / SSIM 画质", "门禁自身失败:%s" % e)
1609
+ print(" [FAIL][vmaf]", e)
1610
+ return None
1611
+ gate = (res or {}).get("gate") or {}
1612
+ v = (res or {}).get("vmaf") or {}
1613
+ s = (res or {}).get("ssim") or {}
1614
+ bits = []
1615
+ if v.get("score") is not None:
1616
+ bits.append("VMAF %.2f" % float(v["score"]))
1617
+ if s.get("score") is not None:
1618
+ bits.append("SSIM %.4f" % float(s["score"]))
1619
+ bits.append("%s 帧" % ((res or {}).get("frames_compared") or 0))
1620
+ detail = " · ".join(bits)
1621
+ if (res or {}).get("elapsed_s") is not None:
1622
+ detail += " · 用时 %.1fs" % float(res["elapsed_s"])
1623
+ st = str(gate.get("status") or "skipped")
1624
+ for w in (res or {}).get("warnings") or []:
1625
+ print(" [WARN][vmaf] %s" % w)
1626
+ for e in (res or {}).get("errors") or []:
1627
+ print(" [FAIL][vmaf] %s" % e)
1628
+ if st == "fail":
1629
+ lg.fail("G11", "全片 VMAF / SSIM 画质", detail + ";" + str(gate.get("detail") or ""))
1630
+ elif st == "skipped":
1631
+ lg.skip("G11", "全片 VMAF / SSIM 画质", str(gate.get("detail") or "本次没跑"))
1632
+ else:
1633
+ lg.ok("G11", "全片 VMAF / SSIM 画质", detail)
1634
+ print(" [vmaf] %s %s" % (st, detail))
1635
+ return res
1636
+
1637
+
1638
+ # v0.8 实测标定(同一部模板片,29 s,faster-whisper small / CPU int8):
1639
+ # 干净成片:字符召回 0.828(专有名词 "DeepSeek 4.1 Flash" 被听成 DIXX/DeepSeek 变体,
1640
+ # 4 段全对上、缺句 0)—— 这不是流水线坏了,是 ASR 对中英混排专名的正常误差。
1641
+ # 所以默认线取 0.85 / 0.72:**低于 0.72 才说明"整句丢了/音轨跟剧本不是一套"**,
1642
+ # 0.72–0.85 之间给 warning(仍然出片,但提示可能串了缓存)。
1643
+ # 想更严就按片覆盖 film.json 的 qa.asr.recall_pass / recall_warn。
1644
+ QA_ASR_RECALL_PASS = 0.85
1645
+ QA_ASR_RECALL_WARN = 0.72
1646
+
1647
+
1648
+ def _qa_asr_gate(lg, film, work, script, tl=None, cfg=None):
1649
+ """ASR 回读门禁(v0.8):把成片音轨转回文字,和剧本逐句对账。
1650
+
1651
+ 为什么值得:容器/响度/帧级质检都默认"音轨里念的就是剧本那几句"。TTS 缓存串了、
1652
+ 换声线漏句、混音压没了、音轨是旧的——这些从波形和容器上都看不出来,只有转回
1653
+ 文字对账才能发现。代价是每片多 4–6 s(small 模型,CPU),可关。
1654
+ """
1655
+ qa = cfg if isinstance(cfg, dict) else {}
1656
+ a = qa.get("asr") if isinstance(qa.get("asr"), dict) else {}
1657
+ if a.get("enabled") is False:
1658
+ lg.skip("G12", "ASR 回读(原稿 vs 成片旁白)",
1659
+ "本次显式关闭(qa.asr.enabled=false)")
1660
+ return None
1661
+ qa_mod = _try_module("qaasr")
1662
+ if qa_mod is None:
1663
+ lg.skip("G12", "ASR 回读(原稿 vs 成片旁白)", "没有 qaasr.py(拷一份进来才能做回读)")
1664
+ return None
1665
+ kw = dict(recall_pass=QA_ASR_RECALL_PASS, recall_warn=QA_ASR_RECALL_WARN)
1666
+ if a.get("recall_pass") is not None:
1667
+ kw["recall_pass"] = float(a["recall_pass"])
1668
+ if a.get("recall_warn") is not None:
1669
+ kw["recall_warn"] = float(a["recall_warn"])
1670
+ if a.get("model"):
1671
+ kw["model_size"] = str(a["model"])
1672
+ print(" [asr] 回读成片音轨(模型 %s,CPU)" % (kw.get("model_size") or "small"))
1673
+ try:
1674
+ res = qa_mod.report(film, tl, script,
1675
+ out_json=os.path.join(str(work), "qa", "asr.json"), **kw)
1676
+ except Exception as e: # noqa: BLE001
1677
+ lg.fail("G12", "ASR 回读(原稿 vs 成片旁白)", "门禁自身失败:%s" % e)
1678
+ print(" [FAIL][asr]", e)
1679
+ return None
1680
+ gate = (res or {}).get("gate") or {}
1681
+ detail = "字符召回 %.3f(%s)" % (float((res or {}).get("recall") or 0.0),
1682
+ (res or {}).get("engine") or "-")
1683
+ if (res or {}).get("elapsed_s") is not None:
1684
+ detail += " · %.1fs" % float(res["elapsed_s"])
1685
+ for w in (res or {}).get("warnings") or []:
1686
+ print(" [WARN][asr] %s" % w)
1687
+ for e in (res or {}).get("errors") or []:
1688
+ print(" [FAIL][asr] %s" % e)
1689
+ st = str(gate.get("status") or "skipped")
1690
+ if st == "fail":
1691
+ lg.fail("G12", "ASR 回读(原稿 vs 成片旁白)", detail + ";" + str(gate.get("detail") or ""))
1692
+ elif st == "skipped":
1693
+ lg.skip("G12", "ASR 回读(原稿 vs 成片旁白)", str(gate.get("detail") or "本次没跑"))
1694
+ else:
1695
+ lg.ok("G12", "ASR 回读(原稿 vs 成片旁白)", detail)
1696
+ print(" [asr] %s %s" % (st, detail))
1697
+ return res
1698
+
1699
+
1700
+ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
1217
1701
  print("== QA")
1218
1702
  th = _theme_for(script) if script is not None else None
1219
1703
  shots, refs = [], [] # refs: [(frac 下标, PIL 原图)]
@@ -1238,8 +1722,9 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
1238
1722
  try:
1239
1723
  fr = scenes_mod.draw_frame(s, sec - s["start"], s["dur"], sec, ref_script,
1240
1724
  int(round(sec * tl["fps"])))
1241
- fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, ref_script.get("sub_max_hold")),
1242
- theme=th)
1725
+ _hold = ref_script.get("sub_max_hold")
1726
+ fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, _hold), theme=th,
1727
+ words=K.sub_words_at(tl["scenes"], sec, _hold))
1243
1728
  refs.append((k, fr))
1244
1729
  except Exception as e: # noqa: BLE001
1245
1730
  print(" [WARN] 参考帧渲染失败:", e)
@@ -1253,9 +1738,13 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
1253
1738
  print(" 锐度参考帧: 场景函数重渲染 t=%.2fs(第 %d 张)" % (ref_sec, k_ref + 1))
1254
1739
  elif scenes_mod is None:
1255
1740
  print(" [WARN] 没有场景函数参考帧(web 引擎或 scenes.py 缺失),锐度比门禁本次跳过")
1741
+ _vq = tl.get("video") or {}
1256
1742
  rep = K.qa_report(film, expect_frames=tl["frames"], ref_img=ref_img,
1257
1743
  ref_sec=ref_sec, expected_subtitles=len(tl["scenes"]),
1258
- notes=os.path.join(work, "sub.srt"), theme=th)
1744
+ notes=os.path.join(work, "sub.srt"), theme=th,
1745
+ expect_w=int(_vq.get("out_w") or K.W),
1746
+ expect_h=int(_vq.get("out_h") or K.H),
1747
+ expect_fps=float(_vq.get("fps") or tl.get("fps") or K.FPS))
1259
1748
  pr = rep["probe"]
1260
1749
  f = pr.get("format", {})
1261
1750
  print(" 时长 %.2fs 大小 %.1f MB 码率 %.2f Mbps" % (
@@ -1303,6 +1792,10 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
1303
1792
  lg = _gate()
1304
1793
  if lg is not None:
1305
1794
  _qa_gates(lg, rep, deep, tl, work)
1795
+ # 全片 VMAF/SSIM 放在最后跑:它最贵(约 1.2× 实时),前面有 fail 时可省下这十分钟。
1796
+ _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=vmaf_mode)
1797
+ # ASR 回读最便宜(模板片 6 s),放最后跑:前面已经 fail 的片没必要再花这 6 秒。
1798
+ _qa_asr_gate(lg, film, work, script, tl, cfg=((script or {}).get("qa") or {}))
1306
1799
  return errors
1307
1800
 
1308
1801
 
@@ -1364,8 +1857,10 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
1364
1857
  if tuple(fr.size) != (K.W, K.H):
1365
1858
  raise RuntimeError("场景 %s 在 t=%.2fs 返回 %dx%d,期望 %dx%d(stills 与 FrameSink 一样严格)"
1366
1859
  % (sid, tg, fr.size[0], fr.size[1], K.W, K.H))
1367
- fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, script.get("sub_max_hold")),
1368
- theme=_theme_for(script))
1860
+ _hold = script.get("sub_max_hold")
1861
+ fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, _hold),
1862
+ theme=_theme_for(script),
1863
+ words=K.sub_words_at(tl["scenes"], tg, _hold))
1369
1864
  p = os.path.join(outdir, "%s_%05.1f.png" % (sid, float(lt)))
1370
1865
  fr.save(p)
1371
1866
  made.append(p)
@@ -1464,6 +1959,15 @@ def _finish_gates(a, persist=True):
1464
1959
  print(" 账本:", gj)
1465
1960
  if rep:
1466
1961
  print(" 报告:", rep, " <- 交付时把这个文件一起给出")
1962
+ try:
1963
+ _run_dir, _err = GT.archive_run(a.work, lg, report_path=rep,
1964
+ artifacts=[str(a.out) if getattr(a, "out", "") else ""])
1965
+ if _run_dir:
1966
+ print(" 归档:", _run_dir, "(work/runs/%s/)" % lg.run_id)
1967
+ elif _err:
1968
+ print(" [WARN] 归档失败:", _err)
1969
+ except Exception as e: # noqa: BLE001
1970
+ print(" [WARN] 归档失败:", e)
1467
1971
  if c[GT.SKIP]:
1468
1972
  print(" 注意:有 %d 项本次**没有执行**(skipped != 通过)" % c[GT.SKIP])
1469
1973
  if errs:
@@ -1474,6 +1978,150 @@ def _finish_gates(a, persist=True):
1474
1978
  return 0
1475
1979
 
1476
1980
 
1981
+ # ---------------------------------------------------------------- 批量
1982
+ BATCH_MAX_FILMS = 100
1983
+ BATCH_MAX_BYTES = 1 << 20 # 1 MiB:清单文件本身不该是个大 JSON
1984
+
1985
+
1986
+ def _load_batch(path):
1987
+ """读批量清单 -> [job]。支持两种写法(都接受):
1988
+
1989
+ ["a/film.json", "b/film.json"] # 简写:只给剧本
1990
+ {"films": [{"script": "a/film.json", "work": "a/work",
1991
+ "scenes": "a/scenes.py", "out": "out/a.mp4"}]}
1992
+
1993
+ 限制来自 MoneyPrinterTurbo 的工程经验(B 线调研):批量最容易死在"第 7 条跑一半
1994
+ 才发现清单写错",所以数量/体积先卡死,并且在**开跑之前**全部预检一遍。
1995
+ """
1996
+ p = str(path)
1997
+ if not os.path.exists(p):
1998
+ raise SystemExit("[batch] 清单不存在:%s" % p)
1999
+ if os.path.getsize(p) > BATCH_MAX_BYTES:
2000
+ raise SystemExit("[batch] 清单 %.1f KB 超过 %d KB 上限:拆成几份跑"
2001
+ % (os.path.getsize(p) / 1024.0, BATCH_MAX_BYTES // 1024))
2002
+ try:
2003
+ data = json.load(open(p, encoding="utf-8"))
2004
+ except json.JSONDecodeError as e:
2005
+ raise SystemExit("[batch] 清单不是合法 JSON:%s" % e) from e
2006
+ rows = data.get("films") if isinstance(data, dict) else data
2007
+ if not isinstance(rows, list) or not rows:
2008
+ raise SystemExit("[batch] 清单必须是数组,或 {\"films\": [...]} 且非空")
2009
+ if len(rows) > BATCH_MAX_FILMS:
2010
+ raise SystemExit("[batch] %d 条超过 %d 条上限:拆成几份跑"
2011
+ % (len(rows), BATCH_MAX_FILMS))
2012
+ base = os.path.dirname(os.path.abspath(p))
2013
+ jobs = []
2014
+ for i, row in enumerate(rows):
2015
+ if isinstance(row, str):
2016
+ row = {"script": row}
2017
+ if not isinstance(row, dict) or not row.get("script"):
2018
+ raise SystemExit("[batch] 第 %d 条缺少 script 字段:%r" % (i + 1, row))
2019
+
2020
+ def _abs(v, default=""):
2021
+ if not v:
2022
+ return default
2023
+ v = str(v)
2024
+ return v if os.path.isabs(v) else os.path.normpath(os.path.join(base, v))
2025
+
2026
+ script = _abs(row["script"])
2027
+ stem = os.path.splitext(os.path.basename(script))[0]
2028
+ jobs.append(dict(
2029
+ script=script,
2030
+ scenes=_abs(row.get("scenes"), os.path.join(os.path.dirname(script), "scenes.py")),
2031
+ work=_abs(row.get("work"), os.path.join(base, "work_" + stem)),
2032
+ out=_abs(row.get("out"), os.path.join(base, "out", stem + ".mp4")),
2033
+ label=str(row.get("label") or stem),
2034
+ ))
2035
+ return jobs
2036
+
2037
+
2038
+ def run_batch(a):
2039
+ """批量渲染:先全量预检 -> 逐条隔离执行 -> JSON 汇总 + 退出码。
2040
+
2041
+ 失败隔离是这套流程的全部价值:一条剧本写错,不该让另外 9 条都不出片。
2042
+ 但也**不静默**:预检阶段就失败的条目会在开跑前一次性报全,跑的过程中失败的
2043
+ 条目会带着退出码与日志尾进汇总。
2044
+ """
2045
+ jobs = _load_batch(a.batch)
2046
+ print("== Batch(%d 条)" % len(jobs))
2047
+ here = os.path.dirname(os.path.abspath(__file__))
2048
+ fc = os.path.join(here, "filmcheck.py")
2049
+ checks = []
2050
+ bad = 0
2051
+ for j in jobs:
2052
+ row = dict(label=j["label"], script=j["script"], ok=False, errors=[], warnings=[])
2053
+ if not os.path.exists(j["script"]):
2054
+ row["errors"].append("剧本不存在:%s" % j["script"])
2055
+ elif os.path.exists(fc):
2056
+ p = subprocess.run([sys.executable, fc, j["script"], "--json"],
2057
+ capture_output=True, text=True, encoding="utf-8",
2058
+ errors="replace")
2059
+ try:
2060
+ d = json.loads((p.stdout or "").strip() or "{}")
2061
+ except json.JSONDecodeError:
2062
+ d = {}
2063
+ row["ok"] = bool(d.get("ok", p.returncode == 0))
2064
+ row["errors"] = [e.get("msg") if isinstance(e, dict) else str(e)
2065
+ for e in (d.get("errors") or [])][:8]
2066
+ row["warnings"] = len(d.get("warnings") or [])
2067
+ else:
2068
+ row["ok"] = True # 没装校验器:不阻断,但汇总里会说
2069
+ row["warnings"] = -1
2070
+ if not row["ok"]:
2071
+ bad += 1
2072
+ checks.append(row)
2073
+ for r in checks:
2074
+ print(" %s %-12s %s" % ("OK " if r["ok"] else "FAIL", r["label"],
2075
+ ";".join(r["errors"][:3]) if r["errors"] else ""))
2076
+ if bad:
2077
+ rep_p = os.path.join(os.path.dirname(os.path.abspath(jobs[0]["work"])), "batch-report.json")
2078
+ # 预检阶段 work/ 目录通常还不存在(子进程要跑起来才会建),这里必须先建父目录:
2079
+ # 否则"清单里有一条写错"这个**本来只想报错退出**的路径,会先抛 FileNotFoundError,
2080
+ # 用户看到的是"[ERROR] 文件/目录操作失败",既丢了"一条都没跑"的说明,也没有台账。
2081
+ os.makedirs(os.path.dirname(rep_p), exist_ok=True)
2082
+ json.dump(dict(phase="preflight", films=checks), open(rep_p, "w", encoding="utf-8"),
2083
+ ensure_ascii=False, indent=1)
2084
+ raise SystemExit("[batch] 预检有 %d 条不通过,**一条都没跑**(清单/剧本先修好):%s\n"
2085
+ " 明细:%s" % (bad, "、".join(r["label"] for r in checks if not r["ok"]),
2086
+ rep_p))
2087
+ print(" 预检全部通过,开始逐条渲染(单条失败不阻断其余)")
2088
+
2089
+ results = []
2090
+ for j in jobs:
2091
+ print("\n---- [batch] %s ----" % j["label"])
2092
+ cmd = [sys.executable, os.path.join(here, "render.py"),
2093
+ "--script", j["script"], "--scenes", j["scenes"],
2094
+ "--work", j["work"], "--out", j["out"], "--stage", "all"]
2095
+ cmd += ["--engine", a.engine] if a.engine else []
2096
+ cmd += ["--cost"] if a.cost else []
2097
+ cmd += ["--strict-gates"] if a.strict_gates else []
2098
+ t0 = time.time()
2099
+ p = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
2100
+ errors="replace")
2101
+ wall = time.time() - t0
2102
+ tail = ((p.stdout or "") + (p.stderr or "")).strip().splitlines()[-6:]
2103
+ results.append(dict(label=j["label"], script=j["script"], out=j["out"],
2104
+ work=j["work"], exit=p.returncode, ok=(p.returncode == 0),
2105
+ wall_s=round(wall, 1), tail=tail))
2106
+ print(" %s 用时 %.1fs -> %s" % ("OK" if p.returncode == 0 else "FAIL", wall, j["out"]))
2107
+ if p.returncode != 0:
2108
+ for ln in tail:
2109
+ print(" | " + ln)
2110
+
2111
+ okn = sum(1 for r in results if r["ok"])
2112
+ rep_p = os.path.join(os.path.dirname(os.path.abspath(jobs[0]["work"])), "batch-report.json")
2113
+ os.makedirs(os.path.dirname(rep_p), exist_ok=True)
2114
+ json.dump(dict(phase="render", films=checks, results=results,
2115
+ summary=dict(total=len(results), ok=okn, failed=len(results) - okn)),
2116
+ open(rep_p, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
2117
+ print("\n== Batch 汇总:%d/%d 成功,%d 失败" % (okn, len(results), len(results) - okn))
2118
+ print(" 台账:%s" % rep_p)
2119
+ for r in results:
2120
+ if not r["ok"]:
2121
+ print(" FAIL %-12s exit=%s" % (r["label"], r["exit"]))
2122
+ return 0 if okn == len(results) else 1
2123
+
2124
+
1477
2125
  # ---------------------------------------------------------------- main
1478
2126
  def main():
1479
2127
  _setup_stdio()
@@ -1506,8 +2154,24 @@ def main():
1506
2154
  help="跳过人工签核检查(会在 QC 报告里留痕,不算通过)")
1507
2155
  ap.add_argument("--cost", action="store_true",
1508
2156
  help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
2157
+ # ---- v0.8 ----
2158
+ ap.add_argument("--batch", default="",
2159
+ help="批量清单(JSON 数组或 {\"films\":[...]},≤100 条):"
2160
+ "先全量预检,再逐条渲染,单条失败不阻断其余")
2161
+ ap.add_argument("--vmaf", default="", choices=("", "full", "sample", "off"),
2162
+ help="qa 阶段的全片 VMAF/SSIM 模式(缺省读 film.json 的 qa.vmaf.mode)")
2163
+ ap.add_argument("--no-preflight", action="store_true",
2164
+ help="跳过渲染前资源预检(会在 QC 报告里留痕,不算通过)")
2165
+ ap.add_argument("--preview", default="",
2166
+ help="只渲前 N 秒做预览片(出 out/preview/*.mp4 + .gif),不写交付报告")
2167
+ ap.add_argument("--run-id", default="",
2168
+ help="本次运行的标签(写进 QC 报告,便于对照多版本)")
1509
2169
  a = ap.parse_args()
1510
2170
 
2171
+ if a.batch:
2172
+ _setup_stdio()
2173
+ return run_batch(a)
2174
+
1511
2175
  # work/ 本来就不存在 = 这一跑自己产出全部中间物,别把"我还没跑到"当成"产物过时"。
1512
2176
  work_pre_exists = os.path.isdir(a.work)
1513
2177
  try:
@@ -1534,7 +2198,7 @@ def main():
1534
2198
  web_entry = "" # 页面还没建:交给后面的 stage 报错,账本先照建
1535
2199
  _LEDGER = GT.GateLedger(a.work, script, scenes_path=a.scenes,
1536
2200
  script_path=a.script, engine=engine,
1537
- web_entry=web_entry)
2201
+ web_entry=web_entry, run_id=a.run_id)
1538
2202
  if a.no_gate:
1539
2203
  _LEDGER.bypass("人工签核检查(--no-gate)", "本条不算通过,只记录")
1540
2204
  print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
@@ -1563,11 +2227,18 @@ def main():
1563
2227
  直接跳过检查连痕都没有——那就成了静默。
1564
2228
  """
1565
2229
  if name:
2230
+ # 本阶段本轮一定真的会跑,所以必须进 pending:`--stage tts` 会顺带排轴、
2231
+ # `--stage stills` 会自动补 tts/layout,这些"顺手跑的下游"不在 _planned(stage)
2232
+ # 里;不加的话 --strict-stale 下刚写完的 tts.json 会把 layout 判成
2233
+ # "输入变了却还要用旧产物",把自动补前置这条路径整个堵死。
2234
+ pending = list(_planned(stage))
2235
+ if name not in pending:
2236
+ pending.append(name)
1566
2237
  errs, _warns = SW.report(a.work, [name], strict=a.strict_stale,
1567
- allow_stale=a.allow_stale,
1568
- ledger=_LEDGER, sources=_sources(),
1569
- self_built=not work_pre_exists,
1570
- pending=_planned(stage))
2238
+ allow_stale=a.allow_stale,
2239
+ ledger=_LEDGER, sources=_sources(),
2240
+ self_built=not work_pre_exists,
2241
+ pending=pending)
1571
2242
  if errs:
1572
2243
  # --strict-stale 的承诺是"上游产物过时算失败"。stagewatch.report 只把
1573
2244
  # 问题放进返回值(并往账本记一条 fail),它自己不抛异常——所以必须在这里
@@ -1672,6 +2343,56 @@ def main():
1672
2343
 
1673
2344
  stage = a.stage
1674
2345
 
2346
+ # ---- preview:前 N 秒试片(v0.8)----
2347
+ if a.preview:
2348
+ try:
2349
+ secs = float(a.preview)
2350
+ except ValueError:
2351
+ raise SystemExit("[preview] 需要秒数,例如 --preview 15")
2352
+ if secs <= 0:
2353
+ raise SystemExit("[preview] 秒数必须 > 0")
2354
+ tl = ensure_tl()
2355
+ mod = load_scenes()
2356
+ pdir = os.path.dirname(os.path.abspath(a.out))
2357
+ os.makedirs(pdir, exist_ok=True)
2358
+ stem = os.path.splitext(os.path.basename(a.out))[0]
2359
+ out_mp4 = os.path.join(pdir, "preview", stem + "_preview.mp4")
2360
+ os.makedirs(os.path.dirname(out_mp4), exist_ok=True)
2361
+ n = min(int(round(secs * float(tl["fps"]))), int(tl["frames"]))
2362
+ print("== Preview(前 %.1fs / %d 帧)" % (n / float(tl["fps"]), n))
2363
+ sc2 = dict(script)
2364
+ sc2["_total"] = tl["total"]
2365
+ sc2.setdefault("_style", resolve_style(script))
2366
+ _render_range(tl, mod, sc2, 0, n, out_mp4, int(script.get("crf", 18)),
2367
+ str(script.get("preset", "medium")), progress=False)
2368
+ # 有 audio.wav 就带上声音(没有也能看画面;不阻断)。
2369
+ # 注意:**必须先把音轨截到预览时长**——K.mux 会校验音视频时长差 < 0.05s,
2370
+ # 直接拿全长 audio.wav 去配 8 秒画面必然被判"音视频时长不一致"(v0.8 开发期实测)。
2371
+ audio = os.path.join(a.work, "audio.wav")
2372
+ if os.path.exists(audio):
2373
+ tmp = out_mp4 + ".mux.mp4"
2374
+ try:
2375
+ pcm, _sr = K.wav_read(audio)
2376
+ need = int(round(n / float(tl["fps"]) * float(_sr)))
2377
+ if need > 0:
2378
+ pcm = pcm[:need]
2379
+ p_audio = os.path.join(a.work, "preview_audio.wav")
2380
+ K.wav_write(p_audio, pcm)
2381
+ audio = p_audio
2382
+ K.mux(out_mp4, audio, tmp, loudnorm=False)
2383
+ os.replace(tmp, out_mp4)
2384
+ except Exception as e: # noqa: BLE001
2385
+ print(" [WARN] 预览片混音失败(保留无声版):%s" % e)
2386
+ gif = os.path.splitext(out_mp4)[0] + ".gif"
2387
+ try:
2388
+ _to_gif(out_mp4, gif, fps=min(10, int(tl["fps"])), width=480)
2389
+ except Exception as e: # noqa: BLE001
2390
+ print(" [WARN] GIF 生成失败(mp4 仍然可用):%s" % e)
2391
+ print(" 预览片:", out_mp4)
2392
+ if os.path.exists(gif):
2393
+ print(" GIF :", gif)
2394
+ return 0
2395
+
1675
2396
  # ---- explain:只报"哪些阶段过时了",不跑任何 stage ----
1676
2397
  if stage == "explain":
1677
2398
  rows = SW.explain(a.work, sources=_sources())
@@ -1754,8 +2475,10 @@ def main():
1754
2475
  _need(os.path.exists(os.path.join(a.work, "style.json")), "style.json 没写出来")
1755
2476
  _LEDGER.ok("style", "风格包解析", str(script["_style"].get("style")))
1756
2477
  _LEDGER.write_json()
1757
- _finish_gates(a)
1758
- return 0
2478
+ # 不能无条件 return 0:--strict-gates 下 skipped 会让 _finish_gates 返回 1,
2479
+ # 账本里也可能已经有 fail —— 门禁结论必须落到退出码,否则 CI 把失败当成功。
2480
+ rc = _finish_gates(a)
2481
+ return 1 if (rc or _LEDGER.fails()) else 0
1759
2482
 
1760
2483
  if stage in ("all", "tts"):
1761
2484
  _t0 = _stage_begin("tts")
@@ -1826,8 +2549,10 @@ def main():
1826
2549
  _stage_end("stills", _t0)
1827
2550
  _LEDGER.add("stills", "母版静帧已产出", GT.OK if made else GT.FAIL,
1828
2551
  "%d 张(要人看过才算过:--stage accept --gate stills)" % len(made or []))
1829
- _finish_gates(a)
1830
- return 0
2552
+ # 0 张静帧 = fail 门禁:以前这里无条件 return 0,`--stills 写错 id` 会
2553
+ # "全部跳过 + 退出码 0",CI 看不出根本没出图。
2554
+ rc = _finish_gates(a)
2555
+ return 1 if (rc or _LEDGER.fails()) else 0
1831
2556
 
1832
2557
  if stage == "qa":
1833
2558
  # qa 不重渲、也不重新合成:缺成片就直接报错;缺 scenes.py 只丢参考帧、不阻断质检。
@@ -1835,7 +2560,7 @@ def main():
1835
2560
  _need(os.path.exists(a.out), "没有成片 %s,先跑 --stage mux" % a.out)
1836
2561
  mod = load_scenes(strict=False) if engine == "pil" else None
1837
2562
  _t0 = _stage_begin("qa")
1838
- errs = st_qa(a.out, tl, a.work, scenes_mod=mod, script=script)
2563
+ errs = st_qa(a.out, tl, a.work, scenes_mod=mod, script=script, vmaf_mode=a.vmaf)
1839
2564
  _stage_end("qa", _t0)
1840
2565
  _check_signoff("qa")
1841
2566
  rc = _finish_gates(a)
@@ -1851,6 +2576,10 @@ def main():
1851
2576
  # audio.wav 已经在前面阶段生成——否则 --stage frames 在空 work 目录会白渲全片再挂掉。
1852
2577
  if stage == "all":
1853
2578
  _need(os.path.exists(audio), "混音阶段异常:audio.wav 未生成")
2579
+ if not _run_preflight(script, a.work, engine, tl=tl,
2580
+ skip=bool(a.no_preflight)):
2581
+ print("\n[渲染前预检未过] 按上面逐条修;确认要硬跑加 --no-preflight(会留痕)")
2582
+ return 1
1854
2583
  if engine == "pil":
1855
2584
  mod = load_scenes()
1856
2585
  _t0 = _stage_begin("frames")
@@ -1859,8 +2588,9 @@ def main():
1859
2588
  _stage_end("frames", _t0, frames=int(tl["frames"]))
1860
2589
  if stage == "frames":
1861
2590
  _LEDGER.ok("frames", "逐帧渲染", "%d 帧(%s)" % (int(tl["frames"]), engine))
1862
- _finish_gates(a)
1863
- return 0 if not _LEDGER.fails() else 1
2591
+ # 同上:strict-gates 下 skipped 也要反映到退出码(以前这里只看 fails())。
2592
+ rc = _finish_gates(a)
2593
+ return 1 if (rc or _LEDGER.fails()) else 0
1864
2594
  if stage in ("all", "mux"):
1865
2595
  # mux 只需要 silent.mp4 + audio.wav:不加载 scenes.py(缺场景模块也能合成)
1866
2596
  _need(os.path.exists(silent), "没有 silent.mp4,先跑 --stage frames")
@@ -1871,11 +2601,13 @@ def main():
1871
2601
  _check_signoff("mux")
1872
2602
  if stage == "mux":
1873
2603
  _LEDGER.ok("mux", "合成(视频流 copy + loudnorm)", a.out)
1874
- _finish_gates(a)
1875
- return 0 if not _LEDGER.fails() else 1
2604
+ # 同上:strict-gates 下 skipped 也要反映到退出码(以前这里只看 fails())。
2605
+ rc = _finish_gates(a)
2606
+ return 1 if (rc or _LEDGER.fails()) else 0
1876
2607
  if stage == "all":
1877
2608
  _t0 = _stage_begin("qa")
1878
- errs = st_qa(film, tl, a.work, scenes_mod=mod, script=script)
2609
+ errs = st_qa(film, tl, a.work, scenes_mod=mod, script=script,
2610
+ vmaf_mode=a.vmaf)
1879
2611
  _stage_end("qa", _t0)
1880
2612
  rc = _finish_gates(a)
1881
2613
  if errs or rc: