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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,11 @@
1
1
  # -*- coding: utf-8 -*-
2
- """render.py — 一条命令从剧本到成片(v0.3.0)。
2
+ """render.py — 一条命令从剧本到成片(v0.5.0)。
3
3
 
4
4
  python render.py --script film.json --scenes scenes.py --out out/film.mp4 [--stage all]
5
5
 
6
6
  阶段(每个 stage 只做自己的事,不顺手重渲全片):
7
- tts 逐句合成旁白 + 实测时长(带输入校验的缓存)
7
+ style 解析风格包 -> work/style.json(画风/配乐/旁白/节奏一次定死,v0.5)
8
+ tts 逐句合成旁白 + 实测时长(带输入校验的缓存;按声线计划换人换语气)
8
9
  layout 由实测时长排时间轴 -> timeline.json / sub.srt / sub.ass(含停留窗口 hold)
9
10
  cues 画面提示点 -> work/cues.json(web 引擎从页面导出,pil 引擎从 film.json 生成)
10
11
  score 程序化配乐 -> work/music.wav(scorekit;无则跳过)
@@ -14,10 +15,17 @@
14
15
  mux 合成(视频流 copy,两遍 loudnorm)
15
16
  qa 质检 + 抽帧 + 联系表 + 帧级深检(闪烁/空窗/音画同步)
16
17
  stills 只渲染若干关键帧做母版预览(不生成视频)
18
+ accept 人工签核一道闸(--gate style|stills|facts|release),写 work/signoff.json
19
+ explain 打印"现在哪些 stage 是旧的、为什么、该跑什么"(--explain-stale 的入口)
17
20
 
18
- 依赖顺序:tts -> layout -> cues -> score -> sfx -> audio -> frames -> mux -> qa。
21
+ 依赖顺序:style -> tts -> layout -> cues -> score -> sfx -> audio -> frames -> mux -> qa。
19
22
  --stage all 按顺序跑完;单跑某个 stage 时,若前置产物缺失会明确报错并提示先跑哪个。
20
23
  --engine 覆盖 film.json 的 engine:pil = Pillow 逐帧(默认,依赖最少);web = 无头浏览器逐帧。
24
+
25
+ v0.6.0 起每个 stage 都往「门禁账本」记结论(pass / fail / skipped 三档),并在
26
+ --stage qa(或 --stage all 收尾)写出 out/QC-REPORT.md:这是**机器可读的交付结论**,
27
+ 取代"agent 在对话里复述质检结果"。人工签核走 --stage accept,绑内容指纹——
28
+ 改了剧本 / scenes.py / 引擎,旧签核自动失效。
21
29
  """
22
30
  from __future__ import annotations
23
31
 
@@ -36,8 +44,22 @@ import numpy as np
36
44
  HERE = Path(__file__).resolve().parent
37
45
  sys.path.insert(0, str(HERE))
38
46
  import filmkit as K
47
+ import gates as GT
48
+ import stagewatch as SW
49
+
50
+ STAGES = ("style", "tts", "layout", "cues", "score", "sfx", "audio", "frames",
51
+ "mux", "qa", "stills", "accept", "explain", "all")
52
+
53
+ # _render_range 每帧都要主题:按 script 对象缓存一次,避免每帧重新解析(几百毫秒的浪费)
54
+ _THEME_CACHE = {}
55
+
56
+ # 本次运行的门禁账本(main() 里建;stage 函数通过它记账)
57
+ _LEDGER = None
58
+
39
59
 
40
- STAGES = ("tts", "layout", "cues", "score", "sfx", "audio", "frames", "mux", "qa", "stills", "all")
60
+ def _gate():
61
+ """取当前账本;被单测直接调用 stage 函数时返回 None(不记账,照常打印)。"""
62
+ return _LEDGER
41
63
 
42
64
 
43
65
  def _setup_stdio():
@@ -152,12 +174,18 @@ def load_script(p) -> dict:
152
174
  for k in ("music", "sfx", "web"):
153
175
  if s.get(k) is not None and not isinstance(s.get(k), dict):
154
176
  raise SystemExit("film.json 的 %s 必须是对象(当前是 %s)" % (k, type(s[k]).__name__))
155
- if s.get("bpm") not in (None, ""):
177
+ # v0.5:bpm 允许 "auto"(由 stylekit 按风格梯度 + 内容指纹决定)。
178
+ # 同时允许写在 music.bpm 里(优先级更高)。
179
+ for _k, _v in (("bpm", s.get("bpm")),
180
+ ("music.bpm", (s.get("music") or {}).get("bpm"))):
181
+ if _v in (None, "") or (isinstance(_v, str) and _v.strip().lower() == "auto"):
182
+ continue
156
183
  try:
157
- if float(s["bpm"]) <= 0:
184
+ if float(_v) <= 0:
158
185
  raise ValueError("必须大于 0")
159
186
  except (TypeError, ValueError) as e:
160
- raise SystemExit("film.json 的 bpm 必须是正数(收到 %r:%s)" % (s["bpm"], e)) from e
187
+ raise SystemExit("film.json 的 %s 必须是正数或 \"auto\"(收到 %r:%s)"
188
+ % (_k, _v, e)) from e
161
189
  seen = set()
162
190
  for i, ln in enumerate(lines):
163
191
  if not isinstance(ln, dict):
@@ -181,10 +209,108 @@ def _need(cond, msg):
181
209
  raise SystemExit("[前置产物缺失] " + msg)
182
210
 
183
211
 
212
+ # ---------------------------------------------------------------- 风格解析(v0.5.0)
213
+ def _stylekit():
214
+ """惰性 import stylekit;缺文件时给一句能照着做的错,而不是 ModuleNotFoundError。"""
215
+ try:
216
+ import stylekit as ST
217
+ return ST
218
+ except Exception as e: # noqa: BLE001
219
+ raise SystemExit("[缺少 stylekit] v0.5 起 render.py 需要同目录的 stylekit.py"
220
+ "(skills/video-studio/scripts/stylekit.py):%s" % e)
221
+
222
+
223
+ def resolve_style(script, work=None, verbose=True):
224
+ """解析风格计划并挂到 script["_style"] 上(同一进程内各 stage 共享)。
225
+
226
+ 纯函数 + 落盘 work/style.json(审计用:用户能看到"为什么是这套风格")。
227
+ """
228
+ if isinstance(script.get("_style"), dict):
229
+ return script["_style"]
230
+ ST = _stylekit()
231
+ plan = ST.resolve_script_style(script)
232
+ # 主题体检:坏主题现在拦,不要等渲完 300 帧
233
+ bad = ST.theme_lint(ST.THEMES[plan["theme_id"]])
234
+ if bad:
235
+ raise SystemExit("[主题体检失败] %s:%s" % (plan["theme_id"], ";".join(bad)))
236
+ script["_style"] = plan
237
+ if work and verbose:
238
+ path = os.path.join(work, "style.json")
239
+ try:
240
+ os.makedirs(work, exist_ok=True)
241
+ json.dump(plan, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
242
+ except Exception as e: # noqa: BLE001
243
+ print(" [WARN] style.json 落盘失败:%s" % e)
244
+ return plan
245
+
246
+
247
+ def _theme_for(script):
248
+ """返回当前片的主题字典;legacy 返回 None(= v0.4 的硬编码外观,逐字节兼容)。"""
249
+ plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
250
+ if plan is not None and plan.get("legacy"):
251
+ return None
252
+ plan = resolve_style(script)
253
+ if plan.get("legacy"):
254
+ return None
255
+ return _stylekit().THEMES[plan["theme_id"]]
256
+
257
+
258
+ def _print_style(plan):
259
+ m = plan["music"]
260
+ v = plan["voice"]
261
+ print(" 风格: %s(%s)%s" % (plan["style"], plan["style_source"],
262
+ "" if plan["legacy"] else " / " + plan["style_name"]))
263
+ print(" 理由: %s" % ";".join(plan["style_reason"]))
264
+ print(" 主题: %s 版式: %s" % (plan["theme_id"],
265
+ ",".join(sorted(set(plan["chapter_layout"].values())))
266
+ or plan["layout_default"]))
267
+ print(" 配乐: %s BPM %g(%s) 调性 %s(%s) mood %s"
268
+ % (m["style"], m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
269
+ if v["map"]:
270
+ print(" 旁白: %s" % " ".join("%s=%s" % kv for kv in sorted(v["map"].items())))
271
+ p = plan["pacing"]
272
+ print(" 节奏: lead %.2f / tail %.2f / min_dur %.1f / 条目步长 %.2f"
273
+ % (p["lead"], p["tail"], p["min_dur"], p["item_stagger"]))
274
+
275
+
276
+ def st_style(script, work):
277
+ print("== Style")
278
+ plan = resolve_style(script, work)
279
+ _print_style(plan)
280
+ return plan
281
+
282
+
184
283
  # ---------------------------------------------------------------- TTS
284
+ def _voice_plan(script, plan):
285
+ """声线计划(voicekit)。缺 voicekit 时返回 None -> tts_batch 走 v0.4 单声线路径。"""
286
+ try:
287
+ import voicekit as VK
288
+ except Exception as e: # noqa: BLE001
289
+ print(" [WARN] 没有 voicekit.py,本次旁白单声线(v0.4 行为):%s" % e)
290
+ return None
291
+ try:
292
+ vp = VK.voice_plan(script, plan)
293
+ except Exception as e: # noqa: BLE001
294
+ print(" [WARN] voicekit 解析失败,退回单声线:%s" % e)
295
+ return None
296
+ return vp
297
+
298
+
185
299
  def st_tts(script, work, force=False):
186
300
  lines = script["lines"]
301
+ plan = resolve_style(script, work)
187
302
  print("== TTS (%d 句)" % len(lines))
303
+ if plan["legacy"]:
304
+ print(" 声线: v0.4 兼容(单一声线 %s)" % script.get("voice", "zh-CN-YunxiNeural"))
305
+ vp = None
306
+ else:
307
+ vp = _voice_plan(script, plan)
308
+ if vp:
309
+ import voicekit as VK
310
+ print(VK.describe(vp))
311
+ json.dump({"style": plan["style"], "policy": plan["voice"]["policy"], "lines": vp},
312
+ open(os.path.join(work, "voice_plan.json"), "w", encoding="utf-8"),
313
+ ensure_ascii=False, indent=1)
188
314
  if force:
189
315
  shutil.rmtree(os.path.join(work, "tts"), ignore_errors=True)
190
316
  done = K.tts_batch(lines, work,
@@ -192,7 +318,12 @@ def st_tts(script, work, force=False):
192
318
  rate=script.get("rate", "+0%"),
193
319
  pitch=script.get("pitch", "+0Hz"),
194
320
  workers=int(script.get("tts_workers", 4)),
195
- trim=bool(script.get("trim_silence", True)))
321
+ trim=bool(script.get("trim_silence", True)),
322
+ plan=None if vp is None else
323
+ [{"voice": r["voice"], "rate": r["rate"], "pitch": r["pitch"],
324
+ "volume": r.get("volume", "+0%"),
325
+ "persona": r.get("persona"), "tone": r.get("tone")} for r in vp],
326
+ trim_db=float(script.get("trim_db", -42.0)))
196
327
  json.dump(done, open(os.path.join(work, "tts.json"), "w", encoding="utf-8"),
197
328
  ensure_ascii=False, indent=1)
198
329
  total = sum(float(x["dur"]) for x in done)
@@ -203,21 +334,46 @@ def st_tts(script, work, force=False):
203
334
  # ---------------------------------------------------------------- 排版
204
335
  def st_layout(script, work, lines=None):
205
336
  lines = lines or json.load(open(os.path.join(work, "tts.json"), encoding="utf-8"))
337
+ plan = resolve_style(script, work)
206
338
  print("== Layout")
339
+ pace = plan["pacing"]
340
+ # 逐句 lead/tail:voicekit 的 voice_plan 已经算好每句的停顿,这里只负责喂给时间轴。
341
+ # 旧项目(legacy)没有 voice_plan.json,lead_map/tail_map 都是 None -> 全局常数,v0.4 行为。
342
+ vp = None
343
+ vp_path = os.path.join(work, "voice_plan.json")
344
+ if not plan["legacy"] and os.path.exists(vp_path):
345
+ try:
346
+ vp = json.load(open(vp_path, encoding="utf-8"))["lines"]
347
+ except Exception as e: # noqa: BLE001
348
+ print(" [WARN] voice_plan.json 读不了,逐句停顿退回全局值:%s" % e)
349
+ lead_map = {r["id"]: float(r["lead"]) for r in vp} if vp else None
350
+ tail_map = {r["id"]: float(r["tail"]) for r in vp} if vp else None
351
+ if vp and script.get("tail_map"):
352
+ tail_extra = script["tail_map"]
353
+ for lid, v in tail_extra.items():
354
+ if lid in tail_map:
355
+ tail_map[lid] = float(v)
207
356
  tl = K.build_timeline(lines,
208
- lead=script.get("lead", 0.55),
209
- tail=script.get("tail", 0.85),
210
- min_dur=script.get("min_dur", 3.0),
211
- tail_map=script.get("tail_map"),
357
+ lead=script.get("lead", pace["lead"]),
358
+ tail=script.get("tail", pace["tail"]),
359
+ min_dur=script.get("min_dur", pace["min_dur"]),
360
+ tail_map=tail_map or script.get("tail_map"),
361
+ lead_map=lead_map,
362
+ hold=script.get("hold", pace["hold"]),
212
363
  max_dur=script.get("max_dur"))
213
364
  tl["title"] = script["title"]
214
365
  tl["voice"] = script.get("voice", "zh-CN-YunxiNeural")
215
366
  tl["crf"] = script.get("crf", 18)
216
367
  tl["preset"] = script.get("preset", "medium")
368
+ tl["style"] = {"style": plan["style"], "theme_id": plan["theme_id"],
369
+ "legacy": plan["legacy"],
370
+ "chapter_layout": plan["chapter_layout"],
371
+ "item_stagger": pace["item_stagger"]}
217
372
  json.dump(tl, open(os.path.join(work, "timeline.json"), "w", encoding="utf-8"),
218
373
  ensure_ascii=False, indent=1)
219
374
  n_srt = K.write_srt(tl["scenes"], os.path.join(work, "sub.srt"))
220
- n_ass = K.write_ass(tl["scenes"], os.path.join(work, "sub.ass"))
375
+ theme = (K._stylekit().THEMES[plan["theme_id"]] if not plan["legacy"] else None)
376
+ n_ass = K.write_ass(tl["scenes"], os.path.join(work, "sub.ass"), theme=theme)
221
377
  print(" 总时长 %.2fs / %d 帧 / %d 段 / srt %d 条 / ass %d 条"
222
378
  % (tl["total"], tl["frames"], len(tl["scenes"]), n_srt, n_ass))
223
379
  # 安全区自查:字幕一旦越界,mux 前就能发现
@@ -359,10 +515,31 @@ def st_score(script, work, tl):
359
515
  s2["start"] = K.cue_time(s2.pop("at"), tl["scenes"])
360
516
  fixed.append(s2)
361
517
  sections = fixed
518
+ plan_s = resolve_style(script)
519
+ mp = plan_s["music"]
362
520
  t0 = time.time()
363
- samples, report = sk.render(float(tl["total"]), bpm=float(script["bpm"]),
364
- key=str(mus.get("key") or "Dm"), anchors=anchors,
365
- sections=sections, seed=int(mus.get("seed", 7)))
521
+ style_arg = None if plan_s["legacy"] else str(mp.get("style") or plan_s["style"])
522
+ kwargs = dict(bpm=float(mp["bpm"]), key=str(mp["key"] or "Dm"), anchors=anchors,
523
+ sections=sections, seed=int(mp["seed_effective"]))
524
+ # scorekit 0.5 起:style/mood + music_plan(一次把 bpm/key/mood/seed 全交出去,
525
+ # 免得两处默认值打架)+ pace(把过碎的段落并进前一段)。
526
+ # 旧 scorekit 没有这些 kwargs -> TypeError -> 自动退回老接口,不阻断出片。
527
+ pace = {"min_dur": float(plan_s["pacing"]["min_dur"]),
528
+ "hold": float(plan_s["pacing"]["hold"])}
529
+ try:
530
+ samples, report = sk.render(float(tl["total"]), style=style_arg,
531
+ mood=str(mp.get("mood") or "steady"),
532
+ music_plan=mp, pace=pace, **kwargs)
533
+ except TypeError:
534
+ try:
535
+ samples, report = sk.render(float(tl["total"]), style=style_arg,
536
+ mood=str(mp.get("mood") or "steady"), **kwargs)
537
+ except TypeError:
538
+ samples, report = sk.render(float(tl["total"]), **kwargs)
539
+ if report and not plan_s["legacy"]:
540
+ print(" 配乐风格: %s / mood %s / %s BPM / %s"
541
+ % (report.get("style") or style_arg, mp.get("mood"),
542
+ report.get("bpm"), report.get("key")))
366
543
  out = os.path.join(work, "music.wav")
367
544
  sk.save(out, samples)
368
545
  if report:
@@ -407,13 +584,18 @@ def st_sfx(script, work, tl):
407
584
 
408
585
 
409
586
  def _voice_track(tl, script):
410
- """把逐句旁白铺成一条总线,返回 (n,) 单声道。"""
587
+ """把逐句旁白铺成一条总线,返回 (n,) 单声道。
588
+
589
+ v0.5.0 修一个 v0.4 的静默 bug:voice_gain 原来在这里和 mix_film 里**各乘一次**
590
+ (净效果 voice_gain^2,默认 1.0 时看不出来)。现在全局增益只在 mix_film 乘一次,
591
+ 这里只负责**逐句**增益(voicekit 的 gain_db,默认 0 dB -> 1.0,与 v0.4 一致)。
592
+ """
411
593
  bus = K.AudioBus(tl["total"])
412
594
  for s in tl["scenes"]:
413
595
  pcm = K.decode_pcm(s["path"])
414
- bus.place(pcm, s["voice_start"], gain=script.get("voice_gain", 1.0),
596
+ gdb = float(s.get("gain_db", 0.0) or 0.0)
597
+ bus.place(pcm, s["voice_start"], gain=10.0 ** (gdb / 20.0),
415
598
  fade_in=0.02, fade_out=0.06)
416
- # 记录真实语音范围,给后续"停顿处补呼吸"之类的处理用
417
599
  return bus.bus[: int(round(float(tl["total"]) * K.SR))].copy()
418
600
 
419
601
 
@@ -487,11 +669,18 @@ def st_audio(tl, work, script):
487
669
  sfx = bus.bus[: int(round(total * K.SR))].copy()
488
670
  print(" 音效: 旧版转场 whoosh(没有 cues.json)")
489
671
 
672
+ plan_s = resolve_style(script)
673
+ # 风格化的 duck 深度:轻快/极简风格的配乐更"薄",让位可以浅一点;
674
+ # 电影/史诗要压得更狠。用户显式写 music.duck_db 时永远以用户为准。
675
+ duck_default = 3.0
676
+ if not plan_s["legacy"]:
677
+ duck_default = {"mono": 2.0, "lo-fi": 2.5, "magazine": 2.5,
678
+ "cinema": 4.5, "epic": 4.5}.get(plan_s["style"], 3.0)
490
679
  samples = K.mix_film(voice, music, sfx, total=total,
491
680
  voice_gain=float(script.get("voice_gain", 1.0)),
492
681
  music_gain_db=float(mus.get("gain_db", -1.5)),
493
682
  sfx_gain_db=float((script.get("sfx") or {}).get("mix_gain_db", 0.0)),
494
- duck_db=float(mus.get("duck_db", 3.0)))
683
+ duck_db=float(mus.get("duck_db", duck_default)))
495
684
  out = os.path.join(work, "audio.wav")
496
685
  peak = K.wav_write(out, samples)
497
686
  print(" audio.wav %.2fs 峰值 %.3f 声道 %d" % (K.wav_dur(out), peak, 2))
@@ -516,14 +705,23 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
516
705
  frame = mod.draw_frame(s, lt, s["dur"], t, script, fi)
517
706
  if frame is None:
518
707
  raise RuntimeError("场景 %s 在 t=%.2fs 返回 None" % (s.get("id"), t))
519
- frame = K.draw_subtitle(frame, K.sub_at(scenes, t, script.get("sub_max_hold")))
708
+ th = _theme_for(script) if _THEME_CACHE.get("k") != id(script) else _THEME_CACHE.get("th")
709
+ if id(script) != _THEME_CACHE.get("k"):
710
+ _THEME_CACHE.update(k=id(script), th=th)
711
+ frame = K.draw_subtitle(frame, K.sub_at(scenes, t, script.get("sub_max_hold")),
712
+ theme=th)
520
713
  fade = 1.0
521
714
  if t < 0.5:
522
715
  fade *= K.ease_out(t / 0.5)
523
716
  if t > total - 0.8:
524
717
  fade *= max(0.0, (total - t) / 0.8)
525
718
  if fade < 0.999:
526
- frame = K.Image.blend(K.Image.new("RGB", (K.W, K.H), (0, 0, 0)), frame, fade)
719
+ # 浅色主题淡入淡出到纯黑会出现"闪一下黑",非常廉价。
720
+ # transition=wash 时淡向主题底色,观感是"渐显"而不是"黑场"。
721
+ wipe = (0, 0, 0)
722
+ if th is not None and str(th.get("transition") or "black") == "wash":
723
+ wipe = tuple(th["bg2"])
724
+ frame = K.Image.blend(K.Image.new("RGB", (K.W, K.H), wipe), frame, fade)
527
725
  sink.write(frame)
528
726
  if progress and fi - last_rep >= 300:
529
727
  last_rep = fi
@@ -540,6 +738,16 @@ def _render_chunk_worker(args):
540
738
  tl = json.load(open(tl_path, encoding="utf-8"))
541
739
  script = json.load(open(script_path, encoding="utf-8"))
542
740
  script["_total"] = tl["total"]
741
+ # v0.6.0 修:JSON 快照会把元组变成 list(主题色是 (r,g,b) 元组),子进程里
742
+ # scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
743
+ # 于是 --jobs >1 整条并行路径直接崩。计划是**纯函数**,这里按剧本重新解一次,
744
+ # 既拿回元组,也保证子进程的主题与父进程逐字段一致。
745
+ script.pop("_style", None)
746
+ try:
747
+ script["_style"] = resolve_style(script, verbose=False)
748
+ except Exception as e: # noqa: BLE001
749
+ print(" [WARN] 子进程重解风格失败,回退到快照里的 _style:%s" % e)
750
+ script["_style"] = json.load(open(script_path, encoding="utf-8")).get("_style")
543
751
  mod = load_module(scenes_path, "film_scenes_chunk_%d" % f0)
544
752
  return _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=False)
545
753
 
@@ -550,6 +758,9 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
550
758
  return _st_frames_web(tl, work, script, jobs=jobs, resume=resume)
551
759
  script = dict(script)
552
760
  script["_total"] = tl["total"] # 场景算全局进度条要用
761
+ # 分块 worker 是 spawn 出来的新进程:主题必须随快照走,否则子进程算出的
762
+ # _theme_for 与父进程不一致 -> 拼接处出现"半部片换了配色"。
763
+ script.setdefault("_style", resolve_style(script))
553
764
  crf = int(script.get("crf", 18))
554
765
  preset = script.get("preset", "medium")
555
766
  total_frames = tl["frames"]
@@ -699,8 +910,99 @@ def st_mux(silent, audio, out, script):
699
910
  return out
700
911
 
701
912
 
913
+ def _qa_gates(lg, rep, deep, tl, work):
914
+ """把 qa_report / 深检的结论拆成逐条门禁记进账本(pass / fail / skipped)。
915
+
916
+ 为什么拆:只有一个"质检通过/失败"的总结论,读报告的人看不出"哪几项其实没执行"。
917
+ 这里每条都带实测值,skipped 必须写明"为什么没执行"。
918
+ """
919
+ pr = rep.get("probe") or {}
920
+ streams = pr.get("streams") or []
921
+ v = next((s for s in streams if s.get("codec_type") == "video"), {})
922
+ a = next((s for s in streams if s.get("codec_type") == "audio"), None)
923
+ fmt = pr.get("format") or {}
924
+ W, H, FPS = K.W, K.H, K.FPS
925
+
926
+ # ---- G4 容器:分辨率 / 帧率 / 音轨 / 时长 ----
927
+ try:
928
+ rate = str(v.get("r_frame_rate") or "")
929
+ num, _, den = rate.partition("/")
930
+ got_fps = (float(num) / float(den or 1)) if num and den else 0.0
931
+ except Exception: # noqa: BLE001
932
+ got_fps = 0.0
933
+ dur = float(fmt.get("duration") or 0)
934
+ want_dur = float(tl["frames"]) / float(tl["fps"])
935
+ detail = "%sx%s@%.3g / %s%s / %.2fs" % (
936
+ v.get("width"), v.get("height"), got_fps,
937
+ ("h264" if v.get("codec_name") == "h264" else str(v.get("codec_name") or "?")),
938
+ ("+aac" if a else "(无音轨)"),
939
+ dur)
940
+ ok4 = (v.get("width") == W and v.get("height") == H
941
+ and abs(got_fps - float(FPS)) <= 0.05 and a is not None
942
+ and abs(dur - want_dur) <= 0.5)
943
+ (lg.ok if ok4 else lg.fail)("G4", "容器层(分辨率/帧率/音轨/时长)", detail)
944
+
945
+ # ---- G5 响度 / 真峰 ----
946
+ lo = rep.get("loudness") or {}
947
+ if "I" in lo and "TP" in lo:
948
+ i_l, tp = float(lo["I"]), float(lo["TP"])
949
+ ok5 = (-19 <= i_l <= -12) and tp <= -1.0
950
+ (lg.ok if ok5 else lg.fail)("G5", "响度 / 真峰(-16 LUFS / ≤ -1 dBTP)",
951
+ "%.1f LUFS · %.1f dBTP" % (i_l, tp))
952
+ else:
953
+ lg.skip("G5", "响度 / 真峰", "ebur128 没测到 %s" % ("I" if "I" not in lo else "TP"))
954
+
955
+ # ---- G6 编码锐度比 ----
956
+ q = rep.get("quality")
957
+ if q and "ratio" in q:
958
+ (lg.ok if q["ratio"] >= 0.85 else lg.fail)(
959
+ "G6", "编码锐度比(文字发虚)", "%.3f(阈值 0.85)" % q["ratio"])
960
+ else:
961
+ why = "没装 cv2" if K._cv2() is None else "没有参考帧(scenes.py 缺失 / web 引擎)"
962
+ lg.skip("G6", "编码锐度比(文字发虚)", why + ",本次没查")
963
+
964
+ # ---- G7 字幕条数(静默丢字幕的唯一检查点)----
965
+ srt = os.path.join(str(work), "sub.srt")
966
+ got = 0
967
+ if os.path.exists(srt):
968
+ got = len([b for b in open(srt, encoding="utf-8").read().strip().split("\n\n") if b.strip()])
969
+ want = len(tl["scenes"])
970
+ if got:
971
+ (lg.ok if got == want else lg.fail)("G7", "字幕条数 = 剧本句数",
972
+ "%d / %d" % (got, want))
973
+ else:
974
+ lg.skip("G7", "字幕条数 = 剧本句数", "没找到 sub.srt")
975
+
976
+ # ---- G8 黑帧 ----
977
+ black_msgs = [w for w in (rep.get("warnings") or []) if "黑帧" in w]
978
+ if black_msgs:
979
+ # qa_report 只在"首尾之外有长黑帧"时写 warning,本身是 warning 级
980
+ lg.skip("G8", "首尾之外无长黑帧", "检测到:" + black_msgs[0])
981
+ else:
982
+ lg.ok("G8", "首尾之外无长黑帧", "0 段")
983
+
984
+ # ---- G9 帧级深检(闪烁 / 空窗 / 音画同步)----
985
+ if deep is None:
986
+ lg.skip("G9", "帧级深检(闪烁/空窗/同步)", "没有 qa_frames.py 或深检失败")
987
+ else:
988
+ nf = len(deep.get("flicker") or [])
989
+ nb = len(deep.get("black_windows") or [])
990
+ ns = len(deep.get("sync") or [])
991
+ judged = (deep.get("sync_info") or {}).get("judged")
992
+ sync_note = ("同步查了 %s 条" % judged) if judged is not None else "同步未判(没写 visual_at)"
993
+ ok9 = nf == 0 and nb == 0 and ns == 0
994
+ detail9 = "闪烁 %d · 空窗 %d · 同步超差 %d(%s)" % (nf, nb, ns, sync_note)
995
+ if ok9 and not judged and os.environ.get("QA_REQUIRE_SYNC"):
996
+ # 与 SKILL 的 QA_REQUIRE_SYNC 同义:要求"真的查过",而不是"没报错"
997
+ lg.fail("G9", "帧级深检(闪烁/空窗/同步超差)",
998
+ detail9 + ";QA_REQUIRE_SYNC=1 要求同步必须被判过")
999
+ else:
1000
+ (lg.ok if ok9 else lg.fail)("G9", "帧级深检(闪烁/空窗/同步超差)", detail9)
1001
+
1002
+
702
1003
  def st_qa(film, tl, work, scenes_mod=None, script=None):
703
1004
  print("== QA")
1005
+ th = _theme_for(script) if script is not None else None
704
1006
  shots, refs = [], [] # refs: [(frac 下标, PIL 原图)]
705
1007
  fracs = (0.08, 0.3, 0.55, 0.78)
706
1008
  ref_script = dict(script) if script is not None else None
@@ -723,7 +1025,8 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
723
1025
  try:
724
1026
  fr = scenes_mod.draw_frame(s, sec - s["start"], s["dur"], sec, ref_script,
725
1027
  int(round(sec * tl["fps"])))
726
- fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, ref_script.get("sub_max_hold")))
1028
+ fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, ref_script.get("sub_max_hold")),
1029
+ theme=th)
727
1030
  refs.append((k, fr))
728
1031
  except Exception as e: # noqa: BLE001
729
1032
  print(" [WARN] 参考帧渲染失败:", e)
@@ -739,7 +1042,7 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
739
1042
  print(" [WARN] 没有场景函数参考帧(web 引擎或 scenes.py 缺失),锐度比门禁本次跳过")
740
1043
  rep = K.qa_report(film, expect_frames=tl["frames"], ref_img=ref_img,
741
1044
  ref_sec=ref_sec, expected_subtitles=len(tl["scenes"]),
742
- notes=os.path.join(work, "sub.srt"))
1045
+ notes=os.path.join(work, "sub.srt"), theme=th)
743
1046
  pr = rep["probe"]
744
1047
  f = pr.get("format", {})
745
1048
  print(" 时长 %.2fs 大小 %.1f MB 码率 %.2f Mbps" % (
@@ -784,6 +1087,9 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
784
1087
  print(" -", e)
785
1088
  else:
786
1089
  print(" [PASS] 质检通过")
1090
+ lg = _gate()
1091
+ if lg is not None:
1092
+ _qa_gates(lg, rep, deep, tl, work)
787
1093
  return errors
788
1094
 
789
1095
 
@@ -819,6 +1125,7 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
819
1125
  print("== Stills")
820
1126
  script = dict(script)
821
1127
  script["_total"] = tl["total"]
1128
+ script.setdefault("_style", resolve_style(script))
822
1129
  outdir = os.path.join(work, "stills")
823
1130
  os.makedirs(outdir, exist_ok=True)
824
1131
  by_id = {s["id"]: s for s in tl["scenes"]}
@@ -844,7 +1151,8 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
844
1151
  if tuple(fr.size) != (K.W, K.H):
845
1152
  raise RuntimeError("场景 %s 在 t=%.2fs 返回 %dx%d,期望 %dx%d(stills 与 FrameSink 一样严格)"
846
1153
  % (sid, tg, fr.size[0], fr.size[1], K.W, K.H))
847
- fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, script.get("sub_max_hold")))
1154
+ fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, script.get("sub_max_hold")),
1155
+ theme=_theme_for(script))
848
1156
  p = os.path.join(outdir, "%s_%05.1f.png" % (sid, float(lt)))
849
1157
  fr.save(p)
850
1158
  made.append(p)
@@ -856,6 +1164,42 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
856
1164
  return made
857
1165
 
858
1166
 
1167
+ def _finish_gates(a):
1168
+ """一次运行收尾:落 gates.json + 写 out/QC-REPORT.md + 统一退出码。"""
1169
+ lg = _LEDGER
1170
+ if lg is None:
1171
+ return 0
1172
+ errs = lg.exit_errors(strict=bool(getattr(a, "strict_gates", False)))
1173
+ try:
1174
+ gj = lg.write_json()
1175
+ except Exception as e: # noqa: BLE001
1176
+ print("[WARN] gates.json 写入失败:", e)
1177
+ gj = ""
1178
+ try:
1179
+ rep = GT.write_report(a.work, os.path.join(
1180
+ os.path.dirname(os.path.abspath(a.out)), "QC-REPORT.md"))
1181
+ except Exception as e: # noqa: BLE001
1182
+ print("[WARN] QC 报告写入失败:", e)
1183
+ rep = ""
1184
+ c = lg.counts()
1185
+ print("")
1186
+ print("== 门禁汇总:pass %d · fail %d · skipped %d%s"
1187
+ % (c[GT.OK], c[GT.FAIL], c[GT.SKIP],
1188
+ "(--strict-gates:skipped 也算失败)" if getattr(a, "strict_gates", False) else ""))
1189
+ if gj:
1190
+ print(" 账本:", gj)
1191
+ if rep:
1192
+ print(" 报告:", rep, " <- 交付时把这个文件一起给出")
1193
+ if c[GT.SKIP]:
1194
+ print(" 注意:有 %d 项本次**没有执行**(skipped != 通过)" % c[GT.SKIP])
1195
+ if errs:
1196
+ print(" [FAIL]")
1197
+ for e in errs:
1198
+ print(" -", e)
1199
+ return 1
1200
+ return 0
1201
+
1202
+
859
1203
  # ---------------------------------------------------------------- main
860
1204
  def main():
861
1205
  _setup_stdio()
@@ -872,6 +1216,22 @@ def main():
872
1216
  ap.add_argument("--force-tts", action="store_true", help="丢弃 TTS 缓存重新合成")
873
1217
  ap.add_argument("--force-music", action="store_true", help="丢弃配乐重出(score 阶段)")
874
1218
  ap.add_argument("--stills", default="", help="stills 阶段用:逗号分隔的 id:秒")
1219
+ ap.add_argument("--gate", default="", choices=("",) + GT.GATE_ORDER,
1220
+ help="accept 阶段用:要签核哪一道闸(%s)" % " / ".join(GT.GATE_ORDER))
1221
+ ap.add_argument("--verdict", default="approved", choices=("approved", "rejected"),
1222
+ help="accept 阶段用:签核结论(rejected 视为没签)")
1223
+ ap.add_argument("--note", default="", help="accept 阶段用:签核备注(写进报告)")
1224
+ ap.add_argument("--by", default="", help="accept 阶段用:签核人(缺省 user)")
1225
+ ap.add_argument("--strict-gates", action="store_true",
1226
+ help="把 skipped 门禁也算失败(对齐 QA_REQUIRE_SYNC 语义)")
1227
+ ap.add_argument("--strict-stale", action="store_true",
1228
+ help="上游产物过时算失败(默认只告警)")
1229
+ ap.add_argument("--allow-stale", action="store_true",
1230
+ help="过时也照跑(会在 QC 报告里留痕)")
1231
+ ap.add_argument("--no-gate", action="store_true",
1232
+ help="跳过人工签核检查(会在 QC 报告里留痕,不算通过)")
1233
+ ap.add_argument("--cost", action="store_true",
1234
+ help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
875
1235
  a = ap.parse_args()
876
1236
 
877
1237
  try:
@@ -884,6 +1244,62 @@ def main():
884
1244
  raise SystemExit("engine 只能是 pil 或 web,收到 %r" % engine)
885
1245
  script["_engine"] = engine
886
1246
  script["_scenes"] = str(Path(a.scenes).resolve())
1247
+
1248
+ # ---- 门禁账本(v0.6.0)----
1249
+ global _LEDGER
1250
+ _LEDGER = GT.GateLedger(a.work, script, scenes_path=a.scenes,
1251
+ script_path=a.script, engine=engine)
1252
+ if a.no_gate:
1253
+ _LEDGER.bypass("人工签核检查(--no-gate)", "本条不算通过,只记录")
1254
+ print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
1255
+
1256
+ def _sources():
1257
+ """源头输入:剧本与 scenes.py(改它们等于改了所有下游的依据)。"""
1258
+ return {"script": a.script, "scenes": a.scenes}
1259
+
1260
+ def _stage_begin(name):
1261
+ """stage 开始时查新鲜度并计时。返回 t0。"""
1262
+ if name and not a.allow_stale:
1263
+ SW.report(a.work, [name], strict=a.strict_stale, allow_stale=False,
1264
+ ledger=_LEDGER, sources=_sources())
1265
+ return time.time()
1266
+
1267
+ def _stage_end(name, t0, **extra):
1268
+ """stage 结束时落盘:manifest 快照 + 耗时台账。"""
1269
+ if not name:
1270
+ return
1271
+ try:
1272
+ SW.snapshot(a.work, name, _sources())
1273
+ except Exception as e: # noqa: BLE001
1274
+ print(" [WARN] manifest 快照失败:", e)
1275
+ wall = time.time() - t0
1276
+ if a.cost:
1277
+ try:
1278
+ GT.append_cost(a.work, name, wall, **extra)
1279
+ except Exception as e: # noqa: BLE001
1280
+ print(" [WARN] cost.jsonl 写入失败:", e)
1281
+ print(" [time] %s 用时 %.1fs%s" % (name, wall, "(已记 work/cost.jsonl)" if a.cost else ""))
1282
+
1283
+ def _check_signoff(where):
1284
+ """交付前的签核检查:缺签只告警,--strict-gates 下算失败。"""
1285
+ if a.no_gate:
1286
+ return
1287
+ state, stale = GT.signoff_state(a.work, _LEDGER.content_hash)
1288
+ bad = [g for g, v in state.items() if v["status"] == GT.SKIP]
1289
+ rejected = [g for g, v in state.items() if v["status"] == GT.FAIL]
1290
+ for g in rejected:
1291
+ _LEDGER.fail("signoff:" + g, "人工签核:" + GT.GATES[g], state[g]["detail"])
1292
+ if bad:
1293
+ _LEDGER.skip("signoff", "人工签核(--stage accept 签核)",
1294
+ "未签/已失效:%s%s"
1295
+ % ("、".join(bad), ("(内容已变:%s)" % "、".join(stale)) if stale else ""))
1296
+ print(" [WARN] 人工签核未完成:%s" % "、".join(bad))
1297
+ print(" 交付前请 --stage accept --gate <名字>;"
1298
+ "--strict-gates 会把它算作失败")
1299
+ for g in bad:
1300
+ print(" %-8s %s" % (g, GT.GATES[g]))
1301
+ elif not rejected:
1302
+ _LEDGER.ok("signoff", "人工签核", "%d/4 道闸已签" % len(GT.GATE_ORDER))
887
1303
  if a.force_music:
888
1304
  plan, _why = K.music_plan(script, has_music_file=os.path.exists(os.path.join(a.work, "music.wav")))
889
1305
  if plan in ("off", "bed", "file"):
@@ -924,29 +1340,114 @@ def main():
924
1340
  return mod
925
1341
 
926
1342
  stage = a.stage
1343
+
1344
+ # ---- explain:只报"哪些阶段过时了",不跑任何 stage ----
1345
+ if stage == "explain":
1346
+ rows = SW.explain(a.work, sources=_sources())
1347
+ bad = 0
1348
+ for r in rows:
1349
+ if r["issues"]:
1350
+ bad += 1
1351
+ print("%s%-8s %s" % ("!! " if r["issues"] else "OK ", r["stage"],
1352
+ r["at"] or "(无记录)"))
1353
+ for it in r["issues"]:
1354
+ print(" -", it["reason"])
1355
+ if it.get("rerun"):
1356
+ print(" 最小重跑:", it["rerun"])
1357
+ print()
1358
+ if bad:
1359
+ print("%d 个阶段过时。默认只告警;--strict-stale 让它失败。" % bad)
1360
+ return 1 if a.strict_stale else 0
1361
+ print("没有发现过时的阶段(按 work/manifest.json 判断)。")
1362
+ return 0
1363
+
1364
+ # ---- accept:人工签核一道闸(写 work/signoff.json,绑内容指纹)----
1365
+ if stage == "accept":
1366
+ if not a.gate:
1367
+ raise SystemExit("[accept] 必须指定要签哪一道闸:--gate %s"
1368
+ % " / ".join(GT.GATE_ORDER))
1369
+ if a.verdict == "approved":
1370
+ # 签核前必须能证明"真的看过了":证据产物不在就拒绝签(不然签核是橡皮图章)。
1371
+ # 证据可能在 work/(渲染产物),也可能在项目根(FACTS.md 是人写的输入),两处都找。
1372
+ proj = os.path.dirname(os.path.abspath(a.script))
1373
+ want = {"style": [os.path.join(a.work, "style.json"),
1374
+ os.path.join(a.work, "voice_plan.json")],
1375
+ "stills": [os.path.join(a.work, "qa", "sheet.png")],
1376
+ "facts": [os.path.join(proj, "FACTS.md"), os.path.join(a.work, "FACTS.md")],
1377
+ "release": [a.out]}.get(a.gate, [])
1378
+ # 每一组是"多选一":任一条路径存在就算有证据。
1379
+ missing = None
1380
+ if want and not any(os.path.exists(str(w)) for w in want):
1381
+ missing = "、".join(os.path.relpath(w, proj) if os.path.isabs(w) else w
1382
+ for w in want)
1383
+ if missing:
1384
+ raise SystemExit("[accept] 证据不存在,拒绝签核:先跑出要看的东西(缺 %s)"
1385
+ % missing)
1386
+ rec = GT.write_signoff(a.work, a.gate, _LEDGER.content_hash,
1387
+ verdict=a.verdict, by=a.by or "user",
1388
+ note=a.note,
1389
+ artifacts=[os.path.join(a.work, "gates.json")])
1390
+ print("[accept] %s = %s(内容指纹 %s,%s)"
1391
+ % (a.gate, rec["verdict"], _LEDGER.content_hash, rec["atText"]))
1392
+ if a.verdict == "rejected":
1393
+ print("[accept] 已记「否决」(不算签核通过);改了内容后需重新签。")
1394
+ p_rep = GT.write_report(a.work, os.path.join(
1395
+ os.path.dirname(os.path.abspath(a.work)), "out", "QC-REPORT.md"))
1396
+ print("[accept] QC 报告已更新:", p_rep)
1397
+ return 0
1398
+
1399
+ if stage in ("all", "style"):
1400
+ _nm = "style" if stage == "style" else None
1401
+ _t0 = _stage_begin(_nm)
1402
+ resolve_style(script, a.work)
1403
+ _stage_end(_nm, _t0, n_tts=None)
1404
+ if stage == "style":
1405
+ print("== Style")
1406
+ _print_style(script["_style"])
1407
+ _need(os.path.exists(os.path.join(a.work, "style.json")), "style.json 没写出来")
1408
+ _LEDGER.ok("style", "风格包解析", str(script["_style"].get("style")))
1409
+ _LEDGER.write_json()
1410
+ _finish_gates(a)
1411
+ return 0
1412
+
927
1413
  if stage in ("all", "tts"):
1414
+ _t0 = _stage_begin("tts")
928
1415
  lines = st_tts(script, a.work, force=a.force_tts)
1416
+ _stage_end("tts", _t0, n_tts=len(lines) if isinstance(lines, list) else None)
1417
+ # 既有行为:单跑 tts 也会排一次轴(tts.json 的时长信息要用),这里一并计时。
1418
+ _t0 = _stage_begin("layout")
929
1419
  tl = st_layout(script, a.work, lines)
930
- if stage in ("all", "layout"):
931
- if stage != "all":
932
- _need(os.path.exists(tts_p), "没有 tts.json,先跑 --stage tts")
933
- tl = st_layout(script, a.work)
1420
+ _stage_end("layout", _t0)
1421
+ if stage == "layout":
1422
+ _t0 = _stage_begin("layout")
1423
+ _need(os.path.exists(tts_p), "没有 tts.json,先跑 --stage tts")
1424
+ tl = st_layout(script, a.work)
1425
+ _stage_end("layout", _t0)
934
1426
  if stage in ("all", "cues"):
935
1427
  tl = tl if stage == "all" else ensure_tl()
936
- st_cues(script, a.work, tl, engine=engine)
1428
+ _t0 = _stage_begin("cues")
1429
+ cues = st_cues(script, a.work, tl, engine=engine)
1430
+ _stage_end("cues", _t0,
1431
+ n_cues=(len((cues or {}).get("cues") or []) if isinstance(cues, dict) else None))
937
1432
  if stage in ("all", "score"):
938
1433
  tl = tl if stage == "all" else ensure_tl()
1434
+ _t0 = _stage_begin("score")
939
1435
  st_score(script, a.work, tl)
1436
+ _stage_end("score", _t0)
940
1437
  if stage in ("all", "sfx"):
941
1438
  tl = tl if stage == "all" else ensure_tl()
1439
+ _t0 = _stage_begin("sfx")
942
1440
  st_sfx(script, a.work, tl)
1441
+ _stage_end("sfx", _t0)
943
1442
  if stage in ("all", "audio"):
944
1443
  tl = tl if stage == "all" else ensure_tl()
945
1444
  _need(all(s.get("path") for s in tl["scenes"]), "timeline 里没有旁白路径,先跑 --stage tts")
946
1445
  missing = [s.get("id") for s in tl["scenes"] if not os.path.exists(str(s.get("path") or ""))]
947
1446
  _need(not missing, "旁白文件不存在:%s(tts 目录被删/挪过?重新跑 --stage tts)"
948
1447
  % (missing[:6],))
1448
+ _t0 = _stage_begin("audio")
949
1449
  st_audio(tl, a.work, script)
1450
+ _stage_end("audio", _t0)
950
1451
 
951
1452
  if stage == "stills":
952
1453
  # 母版预览是"上手第一件事",可它需要 timeline。以前干净目录直接 SystemExit,
@@ -965,7 +1466,12 @@ def main():
965
1466
  tl = ensure_tl()
966
1467
  mod = load_scenes()
967
1468
  specs = [x.strip() for x in a.stills.split(",") if x.strip()] if a.stills else None
968
- st_stills(tl, a.work, mod, script, specs)
1469
+ _t0 = _stage_begin("stills")
1470
+ made = st_stills(tl, a.work, mod, script, specs)
1471
+ _stage_end("stills", _t0)
1472
+ _LEDGER.add("stills", "母版静帧已产出", GT.OK if made else GT.FAIL,
1473
+ "%d 张(要人看过才算过:--stage accept --gate stills)" % len(made or []))
1474
+ _finish_gates(a)
969
1475
  return 0
970
1476
 
971
1477
  if stage == "qa":
@@ -973,8 +1479,12 @@ def main():
973
1479
  tl = ensure_tl()
974
1480
  _need(os.path.exists(a.out), "没有成片 %s,先跑 --stage mux" % a.out)
975
1481
  mod = load_scenes(strict=False) if engine == "pil" else None
1482
+ _t0 = _stage_begin("qa")
976
1483
  errs = st_qa(a.out, tl, a.work, scenes_mod=mod, script=script)
977
- return 1 if errs else 0
1484
+ _stage_end("qa", _t0)
1485
+ _check_signoff("qa")
1486
+ rc = _finish_gates(a)
1487
+ return 1 if (errs or rc) else 0
978
1488
 
979
1489
  if stage in ("all", "frames", "mux"):
980
1490
  tl = tl if stage == "all" else ensure_tl()
@@ -988,19 +1498,37 @@ def main():
988
1498
  _need(os.path.exists(audio), "混音阶段异常:audio.wav 未生成")
989
1499
  if engine == "pil":
990
1500
  mod = load_scenes()
1501
+ _t0 = _stage_begin("frames")
991
1502
  silent = st_frames(tl, a.work, mod, script, jobs=a.jobs, resume=a.resume,
992
1503
  engine=engine)
1504
+ _stage_end("frames", _t0, frames=int(tl["frames"]))
1505
+ if stage == "frames":
1506
+ _LEDGER.ok("frames", "逐帧渲染", "%d 帧(%s)" % (int(tl["frames"]), engine))
1507
+ _finish_gates(a)
1508
+ return 0 if not _LEDGER.fails() else 1
993
1509
  if stage in ("all", "mux"):
994
1510
  # mux 只需要 silent.mp4 + audio.wav:不加载 scenes.py(缺场景模块也能合成)
995
1511
  _need(os.path.exists(silent), "没有 silent.mp4,先跑 --stage frames")
996
1512
  _need(os.path.exists(audio), "没有 audio.wav,先跑 --stage audio")
1513
+ _t0 = _stage_begin("mux")
997
1514
  film = st_mux(silent, audio, a.out, script)
1515
+ _stage_end("mux", _t0)
1516
+ _check_signoff("mux")
1517
+ if stage == "mux":
1518
+ _LEDGER.ok("mux", "合成(视频流 copy + loudnorm)", a.out)
1519
+ _finish_gates(a)
1520
+ return 0 if not _LEDGER.fails() else 1
998
1521
  if stage == "all":
1522
+ _t0 = _stage_begin("qa")
999
1523
  errs = st_qa(film, tl, a.work, scenes_mod=mod, script=script)
1000
- if errs:
1524
+ _stage_end("qa", _t0)
1525
+ rc = _finish_gates(a)
1526
+ if errs or rc:
1001
1527
  print("\n[质检未过] 成片已生成但没有通过门禁,按上面 [FAIL] 逐条修。")
1002
1528
  return 1
1003
- return 0
1529
+ return 0
1530
+ rc = _finish_gates(a) if stage == "all" else 0
1531
+ return rc
1004
1532
 
1005
1533
 
1006
1534
  if __name__ == "__main__":