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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +93 -18
  2. package/docs/styles/demo-results.json +76 -0
  3. package/docs/styles/sheet-8-styles.png +0 -0
  4. package/docs/styles/sheet-ai.png +0 -0
  5. package/docs/styles/sheet-craft.png +0 -0
  6. package/docs/styles/sheet-night.png +0 -0
  7. package/index.js +20 -7
  8. package/package.json +13 -3
  9. package/schemas/film.schema.json +599 -0
  10. package/skills/video-studio/SKILL.md +252 -28
  11. package/skills/video-studio/assets/film.json +78 -24
  12. package/skills/video-studio/assets/scenes.py +1005 -42
  13. package/skills/video-studio/assets/web/engine.js +18 -1
  14. package/skills/video-studio/assets/web/film.js +29 -4
  15. package/skills/video-studio/assets/web/index.html +5 -0
  16. package/skills/video-studio/references/audio-variation.md +158 -0
  17. package/skills/video-studio/references/audio.md +96 -6
  18. package/skills/video-studio/references/change-impact.md +54 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/style-packs.md +178 -0
  21. package/skills/video-studio/references/web-engine.md +1 -1
  22. package/skills/video-studio/requirements.txt +13 -1
  23. package/skills/video-studio/scripts/filmcheck.py +725 -0
  24. package/skills/video-studio/scripts/filmkit.py +953 -76
  25. package/skills/video-studio/scripts/gates.py +509 -0
  26. package/skills/video-studio/scripts/qa_frames.py +109 -19
  27. package/skills/video-studio/scripts/render.py +942 -45
  28. package/skills/video-studio/scripts/scorekit.py +1887 -27
  29. package/skills/video-studio/scripts/selfcheck.py +648 -2
  30. package/skills/video-studio/scripts/sfxkit.py +132 -30
  31. package/skills/video-studio/scripts/stagewatch.py +405 -0
  32. package/skills/video-studio/scripts/stylekit.py +1012 -0
  33. package/skills/video-studio/scripts/voicekit.py +880 -0
  34. package/skills/video-studio/scripts/webengine.py +68 -14
  35. package/tools/audio_similarity.py +279 -0
  36. package/tools/ci_check.py +450 -0
  37. package/tools/estimate.py +657 -0
  38. package/tools/selftest_noscipy.py +70 -0
@@ -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():
@@ -121,6 +143,26 @@ def _web_entry(script):
121
143
  return entry
122
144
 
123
145
 
146
+ def _web_where(entry):
147
+ """报错里告诉用户 DURATION / FPS 到底写在哪:入口 HTML + 同目录脚本 + 一条能照抄的 grep。
148
+
149
+ 只报入口 HTML 不够用:模板的 window.DURATION 写在 web/film.js 开头(engine.js 另有
150
+ 兜底默认值),用户拿到 index.html 还是得全文翻。这里把候选文件一起列出来。
151
+ """
152
+ d = os.path.dirname(entry)
153
+ try:
154
+ scripts = sorted(n for n in os.listdir(d)
155
+ if n.lower().endswith((".js", ".mjs", ".html")))
156
+ except OSError:
157
+ scripts = []
158
+ tail = ("要改的文件:%s(入口 HTML)" % entry)
159
+ if scripts:
160
+ tail += ";页面脚本在 %s%s" % (
161
+ d, "(%s)" % " / ".join(scripts) if len(scripts) <= 6 else "")
162
+ tail += "\n 查 DURATION / FPS 定义在哪:grep -n \"window.DURATION\\|global.DURATION\\|DURATION =\" %s" % d
163
+ return tail
164
+
165
+
124
166
  # ---------------------------------------------------------------- 装载
125
167
  def load_module(path: str, name="film_scenes"):
126
168
  """每次都用独立模块名 + 全新 module 对象,避免跨片污染(历史坑:渲染出上一部片)。"""
@@ -152,12 +194,18 @@ def load_script(p) -> dict:
152
194
  for k in ("music", "sfx", "web"):
153
195
  if s.get(k) is not None and not isinstance(s.get(k), dict):
154
196
  raise SystemExit("film.json 的 %s 必须是对象(当前是 %s)" % (k, type(s[k]).__name__))
155
- if s.get("bpm") not in (None, ""):
197
+ # v0.5:bpm 允许 "auto"(由 stylekit 按风格梯度 + 内容指纹决定)。
198
+ # 同时允许写在 music.bpm 里(优先级更高)。
199
+ for _k, _v in (("bpm", s.get("bpm")),
200
+ ("music.bpm", (s.get("music") or {}).get("bpm"))):
201
+ if _v in (None, "") or (isinstance(_v, str) and _v.strip().lower() == "auto"):
202
+ continue
156
203
  try:
157
- if float(s["bpm"]) <= 0:
204
+ if float(_v) <= 0:
158
205
  raise ValueError("必须大于 0")
159
206
  except (TypeError, ValueError) as e:
160
- raise SystemExit("film.json 的 bpm 必须是正数(收到 %r:%s)" % (s["bpm"], e)) from e
207
+ raise SystemExit("film.json 的 %s 必须是正数或 \"auto\"(收到 %r:%s)"
208
+ % (_k, _v, e)) from e
161
209
  seen = set()
162
210
  for i, ln in enumerate(lines):
163
211
  if not isinstance(ln, dict):
@@ -181,10 +229,113 @@ def _need(cond, msg):
181
229
  raise SystemExit("[前置产物缺失] " + msg)
182
230
 
183
231
 
232
+ # ---------------------------------------------------------------- 风格解析(v0.5.0)
233
+ def _stylekit():
234
+ """惰性 import stylekit;缺文件时给一句能照着做的错,而不是 ModuleNotFoundError。"""
235
+ try:
236
+ import stylekit as ST
237
+ return ST
238
+ except Exception as e: # noqa: BLE001
239
+ raise SystemExit("[缺少 stylekit] v0.5 起 render.py 需要同目录的 stylekit.py"
240
+ "(skills/video-studio/scripts/stylekit.py):%s" % e)
241
+
242
+
243
+ def resolve_style(script, work=None, verbose=True):
244
+ """解析风格计划并挂到 script["_style"] 上(同一进程内各 stage 共享)。
245
+
246
+ 纯函数 + 落盘 work/style.json(审计用:用户能看到"为什么是这套风格")。
247
+ """
248
+ if isinstance(script.get("_style"), dict):
249
+ return script["_style"]
250
+ ST = _stylekit()
251
+ plan = ST.resolve_script_style(script)
252
+ # 主题体检:坏主题现在拦,不要等渲完 300 帧。
253
+ # v0.7.2 修:以前只体检**基础主题**(ST.THEMES[theme_id]),而 scenes 实际用的是
254
+ # plan["theme"](= 基础主题 + film.json 的 visual.theme_overrides)。于是用 overrides
255
+ # 把 ink/bg 调成低对比度时,体检照样 PASS,渲完才发现字幕看不清。
256
+ # 实测:overrides ink=[30,30,30] / bg=[10,10,10] -> 基础主题 lint 为空,
257
+ # 最终主题 lint = ['ink 与背景对比度 1.09 < 7.0']。
258
+ bad = ST.theme_lint(plan.get("theme") or ST.THEMES[plan["theme_id"]])
259
+ if bad:
260
+ raise SystemExit("[主题体检失败] %s:%s" % (plan["theme_id"], ";".join(bad)))
261
+ script["_style"] = plan
262
+ if work and verbose:
263
+ path = os.path.join(work, "style.json")
264
+ try:
265
+ os.makedirs(work, exist_ok=True)
266
+ json.dump(plan, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
267
+ except Exception as e: # noqa: BLE001
268
+ print(" [WARN] style.json 落盘失败:%s" % e)
269
+ return plan
270
+
271
+
272
+ def _theme_for(script):
273
+ """返回当前片的主题字典;legacy 返回 None(= v0.4 的硬编码外观,逐字节兼容)。"""
274
+ plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
275
+ if plan is not None and plan.get("legacy"):
276
+ return None
277
+ plan = resolve_style(script)
278
+ if plan.get("legacy"):
279
+ return None
280
+ return _stylekit().THEMES[plan["theme_id"]]
281
+
282
+
283
+ def _print_style(plan):
284
+ m = plan["music"]
285
+ v = plan["voice"]
286
+ print(" 风格: %s(%s)%s" % (plan["style"], plan["style_source"],
287
+ "" if plan["legacy"] else " / " + plan["style_name"]))
288
+ print(" 理由: %s" % ";".join(plan["style_reason"]))
289
+ print(" 主题: %s 版式: %s" % (plan["theme_id"],
290
+ ",".join(sorted(set(plan["chapter_layout"].values())))
291
+ or plan["layout_default"]))
292
+ print(" 配乐: %s BPM %g(%s) 调性 %s(%s) mood %s"
293
+ % (m["style"], m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
294
+ if v["map"]:
295
+ print(" 旁白: %s" % " ".join("%s=%s" % kv for kv in sorted(v["map"].items())))
296
+ p = plan["pacing"]
297
+ print(" 节奏: lead %.2f / tail %.2f / min_dur %.1f / 条目步长 %.2f"
298
+ % (p["lead"], p["tail"], p["min_dur"], p["item_stagger"]))
299
+
300
+
301
+ def st_style(script, work):
302
+ print("== Style")
303
+ plan = resolve_style(script, work)
304
+ _print_style(plan)
305
+ return plan
306
+
307
+
184
308
  # ---------------------------------------------------------------- TTS
309
+ def _voice_plan(script, plan):
310
+ """声线计划(voicekit)。缺 voicekit 时返回 None -> tts_batch 走 v0.4 单声线路径。"""
311
+ try:
312
+ import voicekit as VK
313
+ except Exception as e: # noqa: BLE001
314
+ print(" [WARN] 没有 voicekit.py,本次旁白单声线(v0.4 行为):%s" % e)
315
+ return None
316
+ try:
317
+ vp = VK.voice_plan(script, plan)
318
+ except Exception as e: # noqa: BLE001
319
+ print(" [WARN] voicekit 解析失败,退回单声线:%s" % e)
320
+ return None
321
+ return vp
322
+
323
+
185
324
  def st_tts(script, work, force=False):
186
325
  lines = script["lines"]
326
+ plan = resolve_style(script, work)
187
327
  print("== TTS (%d 句)" % len(lines))
328
+ if plan["legacy"]:
329
+ print(" 声线: v0.4 兼容(单一声线 %s)" % script.get("voice", "zh-CN-YunxiNeural"))
330
+ vp = None
331
+ else:
332
+ vp = _voice_plan(script, plan)
333
+ if vp:
334
+ import voicekit as VK
335
+ print(VK.describe(vp))
336
+ json.dump({"style": plan["style"], "policy": plan["voice"]["policy"], "lines": vp},
337
+ open(os.path.join(work, "voice_plan.json"), "w", encoding="utf-8"),
338
+ ensure_ascii=False, indent=1)
188
339
  if force:
189
340
  shutil.rmtree(os.path.join(work, "tts"), ignore_errors=True)
190
341
  done = K.tts_batch(lines, work,
@@ -192,7 +343,12 @@ def st_tts(script, work, force=False):
192
343
  rate=script.get("rate", "+0%"),
193
344
  pitch=script.get("pitch", "+0Hz"),
194
345
  workers=int(script.get("tts_workers", 4)),
195
- trim=bool(script.get("trim_silence", True)))
346
+ trim=bool(script.get("trim_silence", True)),
347
+ plan=None if vp is None else
348
+ [{"voice": r["voice"], "rate": r["rate"], "pitch": r["pitch"],
349
+ "volume": r.get("volume", "+0%"),
350
+ "persona": r.get("persona"), "tone": r.get("tone")} for r in vp],
351
+ trim_db=float(script.get("trim_db", -42.0)))
196
352
  json.dump(done, open(os.path.join(work, "tts.json"), "w", encoding="utf-8"),
197
353
  ensure_ascii=False, indent=1)
198
354
  total = sum(float(x["dur"]) for x in done)
@@ -203,21 +359,46 @@ def st_tts(script, work, force=False):
203
359
  # ---------------------------------------------------------------- 排版
204
360
  def st_layout(script, work, lines=None):
205
361
  lines = lines or json.load(open(os.path.join(work, "tts.json"), encoding="utf-8"))
362
+ plan = resolve_style(script, work)
206
363
  print("== Layout")
364
+ pace = plan["pacing"]
365
+ # 逐句 lead/tail:voicekit 的 voice_plan 已经算好每句的停顿,这里只负责喂给时间轴。
366
+ # 旧项目(legacy)没有 voice_plan.json,lead_map/tail_map 都是 None -> 全局常数,v0.4 行为。
367
+ vp = None
368
+ vp_path = os.path.join(work, "voice_plan.json")
369
+ if not plan["legacy"] and os.path.exists(vp_path):
370
+ try:
371
+ vp = json.load(open(vp_path, encoding="utf-8"))["lines"]
372
+ except Exception as e: # noqa: BLE001
373
+ print(" [WARN] voice_plan.json 读不了,逐句停顿退回全局值:%s" % e)
374
+ lead_map = {r["id"]: float(r["lead"]) for r in vp} if vp else None
375
+ tail_map = {r["id"]: float(r["tail"]) for r in vp} if vp else None
376
+ if vp and script.get("tail_map"):
377
+ tail_extra = script["tail_map"]
378
+ for lid, v in tail_extra.items():
379
+ if lid in tail_map:
380
+ tail_map[lid] = float(v)
207
381
  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"),
382
+ lead=script.get("lead", pace["lead"]),
383
+ tail=script.get("tail", pace["tail"]),
384
+ min_dur=script.get("min_dur", pace["min_dur"]),
385
+ tail_map=tail_map or script.get("tail_map"),
386
+ lead_map=lead_map,
387
+ hold=script.get("hold", pace["hold"]),
212
388
  max_dur=script.get("max_dur"))
213
389
  tl["title"] = script["title"]
214
390
  tl["voice"] = script.get("voice", "zh-CN-YunxiNeural")
215
391
  tl["crf"] = script.get("crf", 18)
216
392
  tl["preset"] = script.get("preset", "medium")
393
+ tl["style"] = {"style": plan["style"], "theme_id": plan["theme_id"],
394
+ "legacy": plan["legacy"],
395
+ "chapter_layout": plan["chapter_layout"],
396
+ "item_stagger": pace["item_stagger"]}
217
397
  json.dump(tl, open(os.path.join(work, "timeline.json"), "w", encoding="utf-8"),
218
398
  ensure_ascii=False, indent=1)
219
399
  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"))
400
+ theme = (K._stylekit().THEMES[plan["theme_id"]] if not plan["legacy"] else None)
401
+ n_ass = K.write_ass(tl["scenes"], os.path.join(work, "sub.ass"), theme=theme)
221
402
  print(" 总时长 %.2fs / %d 帧 / %d 段 / srt %d 条 / ass %d 条"
222
403
  % (tl["total"], tl["frames"], len(tl["scenes"]), n_srt, n_ass))
223
404
  # 安全区自查:字幕一旦越界,mux 前就能发现
@@ -283,6 +464,19 @@ def st_cues(script, work, tl, engine="pil"):
283
464
  except Exception as e: # noqa: BLE001
284
465
  raise SystemExit("scenes.py 的 cues() 钩子报错:%s" % e) from e
285
466
  n_hook = _extend_safe(got, "scenes.py")
467
+ # v0.7.2:web 引擎下要提醒"两套时间基准混用"。
468
+ # scenes.py 的 cues() 是按 **PIL 时间轴**(timeline.json 的段落与 visual_at)算的,
469
+ # 页面动画则按 **页面自己的时间轴**(window.DURATION)。两者合并进同一份 cues.json 后:
470
+ # - 音效会按 PIL 时间响(页面画面此刻可能正在另一幕);
471
+ # - 音画同步门禁能把这种配对识别为"跨基准"并跳过(qa_frames 的 src 过滤),不报假 FAIL,
472
+ # 但**声音与画面对不上的事实仍然存在**。
473
+ # 不静默:说清现象与两条改法,让用户自己选路线。
474
+ if engine == "web" and n_hook:
475
+ print(" [WARN] web 引擎下仍合并了 scenes.py 的 %d 条 cue —— 它们按 PIL 时间轴生成,"
476
+ "页面动画按页面时间轴播放,两者基准不同:音效可能响在错误的幕上。" % n_hook)
477
+ print(" 两种改法:① 走 PIL 路线(--engine pil)用 scenes.py 这套 cue;"
478
+ "② 走 web 路线就把 cue 写进页面(window.getCues())或 film.json 的 sfx.cues,"
479
+ "并删除 scenes.py 的 cues() 钩子。")
286
480
 
287
481
  # web 引擎:从页面导出(需要浏览器)
288
482
  n_web = 0
@@ -359,10 +553,58 @@ def st_score(script, work, tl):
359
553
  s2["start"] = K.cue_time(s2.pop("at"), tl["scenes"])
360
554
  fixed.append(s2)
361
555
  sections = fixed
556
+ plan_s = resolve_style(script)
557
+ mp = plan_s["music"]
362
558
  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)))
559
+ style_arg = None if plan_s["legacy"] else str(mp.get("style") or plan_s["style"])
560
+ kwargs = dict(bpm=float(mp["bpm"]), key=str(mp["key"] or "Dm"), anchors=anchors,
561
+ sections=sections, seed=int(mp["seed_effective"]))
562
+ # v0.7:内容指纹参与配乐结构变奏(和弦替换 / 动机级数 / 鼓型 ghost,见
563
+ # references/audio-variation.md §3.3)。只在非 legacy 时传——不传 fingerprint 的
564
+ # scorekit 认不出这个 kwarg,会走下面的 TypeError 回退链,照样出片只少变奏。
565
+ # legacy 必须不传:v0.4 的逐样本兼容是硬契约(selfcheck 20/26 条守着)。
566
+ fp_hex = ""
567
+ if not plan_s["legacy"]:
568
+ fp_hex = str((plan_s.get("fingerprint") or {}).get("full") or "")
569
+ kw_fp = dict(kwargs, fingerprint=fp_hex) if fp_hex else kwargs
570
+ # scorekit 0.5 起:style/mood + music_plan(一次把 bpm/key/mood/seed 全交出去,
571
+ # 免得两处默认值打架)+ pace(把过碎的段落并进前一段)。
572
+ # 旧 scorekit 没有这些 kwargs -> TypeError -> 自动退回老接口,不阻断出片。
573
+ # 四级回退:带指纹 -> 不带指纹 -> 只有 style/mood -> 最老的签名。
574
+ pace = {"min_dur": float(plan_s["pacing"]["min_dur"]),
575
+ "hold": float(plan_s["pacing"]["hold"])}
576
+ mood_arg = str(mp.get("mood") or "steady")
577
+ samples = report = None
578
+ # v0.7.2:多级签名兼容链以前对**任何** TypeError 都继续换签名试,最后统一报
579
+ # "不接受任何一种已知签名"。scorekit 内部真出 TypeError(例如某个参数是 None 被拿去做
580
+ # 算术)时,用户会照着"签名不兼容"的方向白找半天。这里区分两类:
581
+ # - 报错文本含 "unexpected keyword argument" -> 确实是不认识这个参数,换签名重试;
582
+ # - 其它 TypeError -> 是**内部**错误,带上签名与错误信息立刻报,别吞。
583
+ attempts = (
584
+ dict(style=style_arg, mood=mood_arg, music_plan=mp, pace=pace, **kw_fp),
585
+ dict(style=style_arg, mood=mood_arg, music_plan=mp, pace=pace, **kwargs),
586
+ dict(style=style_arg, mood=mood_arg, **kwargs),
587
+ dict(**kwargs),
588
+ )
589
+ for attempt in attempts:
590
+ try:
591
+ samples, report = sk.render(float(tl["total"]), **attempt)
592
+ break
593
+ except TypeError as err:
594
+ if "unexpected keyword argument" in str(err):
595
+ continue
596
+ raise SystemExit(
597
+ "[score] scorekit.render() 内部报错(不是签名不兼容,别去改调用参数):%s\n"
598
+ " 本次签名:%s\n 要看完整 traceback:set RENDER_DEBUG=1 后重跑"
599
+ % (err, ", ".join(sorted(k for k in attempt if k != "self")))
600
+ ) from err
601
+ if samples is None:
602
+ raise SystemExit("[score] scorekit.render() 不接受任何一种已知签名,无法出配乐"
603
+ "(试过 %d 种:从最全的参数到最简)" % len(attempts))
604
+ if report and not plan_s["legacy"]:
605
+ print(" 配乐风格: %s / mood %s / %s BPM / %s"
606
+ % (report.get("style") or style_arg, mp.get("mood"),
607
+ report.get("bpm"), report.get("key")))
366
608
  out = os.path.join(work, "music.wav")
367
609
  sk.save(out, samples)
368
610
  if report:
@@ -374,8 +616,82 @@ def st_score(script, work, tl):
374
616
  return out
375
617
 
376
618
 
619
+ def _sfx_seed_context(script):
620
+ """v0.7:算音效的内容派生上下文 (style, content_fp, key, enabled)。
621
+
622
+ legacy(没写 style 字段)时返回全 None -> CueBus 走旧的 seed_base + i 路径,
623
+ v0.4 及以前的片子逐样本不变。参考 references/audio-variation.md §3.2 / §4。
624
+ """
625
+ plan = resolve_style(script)
626
+ sfx_cfg = script.get("sfx") or {}
627
+ want_content = bool(sfx_cfg.get("content_seed", True))
628
+ if plan.get("legacy") or not want_content:
629
+ return None, None, None
630
+ fp = (plan.get("fingerprint") or {}).get("full")
631
+ mus = plan.get("music") or {}
632
+ return str(plan.get("style")), str(fp) if fp else None, str(mus.get("key") or "Dm")
633
+
634
+
635
+ def _duck_transition_sfx(samples, cues, scenes, sr=K.SR, depth_db=-3.0, window=2.2):
636
+ """段落边界前的转场音效自动让位(v0.7,契约 §4)。
637
+
638
+ 配乐在段落交界自己已经有一段过门(scorekit 的 swell / riser / sub_boom),
639
+ sfx 轨再在同一时刻放 whoosh / riser,同一频段叠两层就是"配乐和音效听起来像同一套"。
640
+ 做法:对位于 [边界-window, 边界] 内的转场类 cue,在它的时长内乘一个 dB 增益包络。
641
+ 只压转场类,不碰 impact / 提示音(那是"该响"的语义重音)。
642
+ """
643
+ if not scenes or samples is None or not len(samples):
644
+ return samples
645
+ try:
646
+ import numpy as np
647
+ except Exception: # noqa: BLE001
648
+ return samples
649
+ x = np.array(samples, copy=True, dtype=np.float32)
650
+ n = len(x)
651
+ bounds = [float(s["start"]) for s in scenes if float(s.get("start", 0.0)) > 0.5]
652
+ if not bounds:
653
+ return x
654
+ kinds = 0
655
+ for c in cues or []:
656
+ if str(c.get("type")) not in ("whoosh", "riser", "reverse_swell"):
657
+ continue
658
+ t = float(c.get("t", -1.0))
659
+ if t < 0:
660
+ continue
661
+ near = any(abs(t - b) <= window for b in bounds)
662
+ if not near:
663
+ continue
664
+ dur = float(c.get("dur") or 1.5)
665
+ s = max(0, int(round(t * sr)))
666
+ e = min(n, int(round((t + dur) * sr)))
667
+ if e <= s:
668
+ continue
669
+ g = 10.0 ** (depth_db / 20.0)
670
+ k = min(80, max(8, int(0.01 * sr)))
671
+ ramp = min((e - s) // 2, k)
672
+ seg = x[s:e]
673
+ if ramp > 0:
674
+ env = np.full(e - s, g, dtype=np.float32)
675
+ env[:ramp] = np.linspace(1.0, g, ramp, dtype=np.float32)
676
+ env[-ramp:] = np.linspace(g, 1.0, ramp, dtype=np.float32)
677
+ seg = seg * env[:, None] if seg.ndim == 2 else seg * env
678
+ else:
679
+ seg = seg * g
680
+ x[s:e] = seg
681
+ kinds += 1
682
+ if kinds:
683
+ print(" 过门避让: %d 条转场音效在段落边界前 %.1fs 内 -%.0f dB(避免和配乐过门撞车)"
684
+ % (kinds, window, abs(depth_db)))
685
+ return x
686
+
687
+
377
688
  def st_sfx(script, work, tl):
378
- """按提示点合成音效轨 -> work/sfx.wav。没有 cues.json 就跳过。"""
689
+ """按提示点合成音效轨 -> work/sfx.wav。没有 cues.json 就跳过。
690
+
691
+ v0.7:非 legacy 时走内容派生 seed(跨片同一位置不再逐样本相同),
692
+ 并把配乐调性透传给提示音配方(不再硬编码 Dm);
693
+ 段落边界前的转场音效自动让位(不与配乐过门撞车)。
694
+ """
379
695
  print("== SFX")
380
696
  cues_path = os.path.join(work, "cues.json")
381
697
  if not os.path.exists(cues_path):
@@ -387,10 +703,12 @@ def st_sfx(script, work, tl):
387
703
  print(" cues.json 里没有提示点,跳过")
388
704
  return None
389
705
  sfx_cfg = script.get("sfx") or {}
706
+ style, content_fp, key = _sfx_seed_context(script)
390
707
  try:
391
708
  samples = bus.build_audio(total=tl["total"],
392
709
  base_db=sfx_cfg.get("base_db"),
393
- default_gain_db=float(sfx_cfg.get("gain_db", 0.0)))
710
+ default_gain_db=float(sfx_cfg.get("gain_db", 0.0)),
711
+ style=style, content_fp=content_fp, key=key)
394
712
  except Exception as e: # noqa: BLE001
395
713
  # 音效轨是"增强"不是"必需":sfxkit 缺失/配方不全时不要拖死整片,
396
714
  # 但必须把原因喊出来(静默降级比报错更危险)。
@@ -399,6 +717,10 @@ def st_sfx(script, work, tl):
399
717
  if os.path.exists(stale):
400
718
  os.remove(stale)
401
719
  return None
720
+ if not sfx_cfg.get("duck_transitions", True):
721
+ pass
722
+ else:
723
+ samples = _duck_transition_sfx(samples, bus.resolved(), tl.get("scenes") or [])
402
724
  out = os.path.join(work, "sfx.wav")
403
725
  peak = K.wav_write(out, samples)
404
726
  print(" sfx.wav %.2fs 峰值 %.3f 声道 %d" % (
@@ -407,13 +729,18 @@ def st_sfx(script, work, tl):
407
729
 
408
730
 
409
731
  def _voice_track(tl, script):
410
- """把逐句旁白铺成一条总线,返回 (n,) 单声道。"""
732
+ """把逐句旁白铺成一条总线,返回 (n,) 单声道。
733
+
734
+ v0.5.0 修一个 v0.4 的静默 bug:voice_gain 原来在这里和 mix_film 里**各乘一次**
735
+ (净效果 voice_gain^2,默认 1.0 时看不出来)。现在全局增益只在 mix_film 乘一次,
736
+ 这里只负责**逐句**增益(voicekit 的 gain_db,默认 0 dB -> 1.0,与 v0.4 一致)。
737
+ """
411
738
  bus = K.AudioBus(tl["total"])
412
739
  for s in tl["scenes"]:
413
740
  pcm = K.decode_pcm(s["path"])
414
- bus.place(pcm, s["voice_start"], gain=script.get("voice_gain", 1.0),
741
+ gdb = float(s.get("gain_db", 0.0) or 0.0)
742
+ bus.place(pcm, s["voice_start"], gain=10.0 ** (gdb / 20.0),
415
743
  fade_in=0.02, fade_out=0.06)
416
- # 记录真实语音范围,给后续"停顿处补呼吸"之类的处理用
417
744
  return bus.bus[: int(round(float(tl["total"]) * K.SR))].copy()
418
745
 
419
746
 
@@ -487,11 +814,33 @@ def st_audio(tl, work, script):
487
814
  sfx = bus.bus[: int(round(total * K.SR))].copy()
488
815
  print(" 音效: 旧版转场 whoosh(没有 cues.json)")
489
816
 
817
+ plan_s = resolve_style(script)
818
+ # 风格化的 duck 深度:轻快/极简风格的配乐更"薄",让位可以浅一点;
819
+ # 电影/史诗要压得更狠。用户显式写 music.duck_db 时永远以用户为准。
820
+ # v0.7:整体 +1 dB —— 旧值(2.0/2.5/3.0/4.5)下配乐只比音效低 1.5 dB、duck 仅 3 dB,
821
+ # 段落过门时两者在同一频段几乎等高,是"配乐和音效听起来像同一套"的混音侧成因。
822
+ duck_default = 4.0
823
+ if not plan_s["legacy"]:
824
+ duck_default = {"mono": 3.0, "lo-fi": 3.5, "magazine": 3.5,
825
+ "cinema": 5.5, "epic": 5.5}.get(plan_s["style"], 4.0)
490
826
  samples = K.mix_film(voice, music, sfx, total=total,
491
827
  voice_gain=float(script.get("voice_gain", 1.0)),
492
- music_gain_db=float(mus.get("gain_db", -1.5)),
828
+ # v0.7.1 旁白优先:配乐基线从 -1.5 dB 降到 -12 dB。
829
+ # 视频以旁白为主,-1.5 dB 时背景与台词响度几乎等高,观众要费力分辨字;
830
+ # 12 dB 是"人声明显压住背景"的经验线(audio.md §2 的那条设计标准)。
831
+ music_gain_db=float(mus.get("gain_db", -12.0)),
493
832
  sfx_gain_db=float((script.get("sfx") or {}).get("mix_gain_db", 0.0)),
494
- duck_db=float(mus.get("duck_db", 3.0)))
833
+ duck_db=float(mus.get("duck_db", duck_default)),
834
+ # 旁白说话时再压背景(侧链):配乐 6 dB、音效 3 dB;
835
+ # 间隙自动恢复,避免整片背景被闷住。显式写 0 可关掉。
836
+ voice_duck_db=float(mus.get("voice_duck_db", 6.0)),
837
+ voice_duck_sfx_db=float(mus.get("voice_duck_sfx_db", 3.0)),
838
+ voice_thresh_db=float(mus.get("voice_thresh_db", -42.0)),
839
+ voice_floor_db=float(mus.get("voice_floor_db", -60.0)),
840
+ # SNR 兜底:混音后实测人声活跃区的人声/背景差,不足 12 dB 自动补压背景。
841
+ # 这是"画面/素材电平再离谱也不许盖住旁白"的最后一道闸;0 = 关。
842
+ guard_snr_db=float(mus.get("guard_snr_db", 12.0)),
843
+ guard_cap_db=float(mus.get("guard_cap_db", 30.0)))
495
844
  out = os.path.join(work, "audio.wav")
496
845
  peak = K.wav_write(out, samples)
497
846
  print(" audio.wav %.2fs 峰值 %.3f 声道 %d" % (K.wav_dur(out), peak, 2))
@@ -516,14 +865,23 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
516
865
  frame = mod.draw_frame(s, lt, s["dur"], t, script, fi)
517
866
  if frame is None:
518
867
  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")))
868
+ th = _theme_for(script) if _THEME_CACHE.get("k") != id(script) else _THEME_CACHE.get("th")
869
+ if id(script) != _THEME_CACHE.get("k"):
870
+ _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)
520
873
  fade = 1.0
521
874
  if t < 0.5:
522
875
  fade *= K.ease_out(t / 0.5)
523
876
  if t > total - 0.8:
524
877
  fade *= max(0.0, (total - t) / 0.8)
525
878
  if fade < 0.999:
526
- frame = K.Image.blend(K.Image.new("RGB", (K.W, K.H), (0, 0, 0)), frame, fade)
879
+ # 浅色主题淡入淡出到纯黑会出现"闪一下黑",非常廉价。
880
+ # transition=wash 时淡向主题底色,观感是"渐显"而不是"黑场"。
881
+ wipe = (0, 0, 0)
882
+ if th is not None and str(th.get("transition") or "black") == "wash":
883
+ wipe = tuple(th["bg2"])
884
+ frame = K.Image.blend(K.Image.new("RGB", (K.W, K.H), wipe), frame, fade)
527
885
  sink.write(frame)
528
886
  if progress and fi - last_rep >= 300:
529
887
  last_rep = fi
@@ -534,14 +892,56 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
534
892
  return sink.close()
535
893
 
536
894
 
895
+ def _seg_usable(path, frames):
896
+ """已有分块能不能复用:体积够 + 头部声明的帧数与计划一致。
897
+
898
+ 为什么不能只看 size>1024:Ctrl-C / 杀进程留下的半截 mp4 也 >1024,被当成
899
+ "已存在"跳过后,拼接只在最后报一句"拼接后时长 0.07s != 预期 29.00s"。
900
+ v0.7.2 起分块先写 .part 再 os.replace,正常中断不会留下半截正式文件;这里
901
+ 再补一道头部 nb_frames 比对,挡住"文件在/大小对但帧数不对"的异常(例如被
902
+ 外部工具截断过)。
903
+
904
+ 注意:**不做** -count_frames 逐帧核对。实测 390 帧的块要 3.5 s(只读头部
905
+ 0.2 s),长片几十个块会让每次 --resume 多花几分钟;被截断的 mp4 头部仍写着
906
+ 完整 nb_frames,逐帧核对才是唯一能查出来的手段,代价与收益不成比例——真遇到
907
+ 这种情况由拼接后的时长校验兜底,并在报错里指明怎么重来。
908
+ """
909
+ try:
910
+ if not os.path.exists(path) or os.path.getsize(path) <= 1024:
911
+ return False
912
+ info = K.probe(path) or {}
913
+ for st in info.get("streams") or []:
914
+ if st.get("codec_type") == "video" and st.get("nb_frames"):
915
+ return int(st["nb_frames"]) == int(frames)
916
+ # 读不出 nb_frames(老封装 / ffprobe 版本差异):退回到"体积够就信"
917
+ return True
918
+ except Exception: # noqa: BLE001
919
+ return False
920
+
921
+
537
922
  def _render_chunk_worker(args):
538
923
  """分块渲染工作单元(供 multiprocessing 调用;函数必须模块级可 pickle)。"""
539
- (tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset) = args
924
+ (tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset, part_path) = args
540
925
  tl = json.load(open(tl_path, encoding="utf-8"))
541
926
  script = json.load(open(script_path, encoding="utf-8"))
542
927
  script["_total"] = tl["total"]
928
+ # v0.6.0 修:JSON 快照会把元组变成 list(主题色是 (r,g,b) 元组),子进程里
929
+ # scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
930
+ # 于是 --jobs >1 整条并行路径直接崩。计划是**纯函数**,这里按剧本重新解一次,
931
+ # 既拿回元组,也保证子进程的主题与父进程逐字段一致。
932
+ script.pop("_style", None)
933
+ try:
934
+ script["_style"] = resolve_style(script, verbose=False)
935
+ except Exception as e: # noqa: BLE001
936
+ print(" [WARN] 子进程重解风格失败,回退到快照里的 _style:%s" % e)
937
+ script["_style"] = json.load(open(script_path, encoding="utf-8")).get("_style")
543
938
  mod = load_module(scenes_path, "film_scenes_chunk_%d" % f0)
544
- return _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=False)
939
+ # 先写 .part 再改名:中途被 Ctrl-C / 被杀时,目标文件名对应的永远是一段**完整**
940
+ # 编码结果。否则半截 mp4 只要 >1024 字节就会被 --resume 当成"已存在"跳过,
941
+ # 拼接后时长对不上才报错(实测:截断到 60 KB 的块 -> 拼接后 0.07s vs 预期 29.00s)。
942
+ _render_range(tl, mod, script, f0, f1, part_path, crf, preset, progress=False)
943
+ os.replace(part_path, out_path)
944
+ return out_path
545
945
 
546
946
 
547
947
  def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
@@ -550,6 +950,9 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
550
950
  return _st_frames_web(tl, work, script, jobs=jobs, resume=resume)
551
951
  script = dict(script)
552
952
  script["_total"] = tl["total"] # 场景算全局进度条要用
953
+ # 分块 worker 是 spawn 出来的新进程:主题必须随快照走,否则子进程算出的
954
+ # _theme_for 与父进程不一致 -> 拼接处出现"半部片换了配色"。
955
+ script.setdefault("_style", resolve_style(script))
553
956
  crf = int(script.get("crf", 18))
554
957
  preset = script.get("preset", "medium")
555
958
  total_frames = tl["frames"]
@@ -602,13 +1005,31 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
602
1005
 
603
1006
  todo = []
604
1007
  planned = set()
1008
+ # 上次被中断留下的半成品(编码中途的残片):不算分块,直接清掉,别让它占盘。
1009
+ # 名字带 _part_ 前缀,既不会被 stale 检测当成"不属于当前计划的旧分块",
1010
+ # 也不会被 _seg_usable 当成"已存在的分块"跳过。
1011
+ for n in os.listdir(seg_dir):
1012
+ if n.startswith("_part_"):
1013
+ try:
1014
+ os.remove(os.path.join(seg_dir, n))
1015
+ except OSError:
1016
+ pass
605
1017
  for (f0, f1) in plan:
606
1018
  seg = os.path.join(seg_dir, "seg_%06d_%06d.mp4" % (f0, f1))
607
1019
  planned.add(os.path.basename(seg))
608
- if resume and os.path.exists(seg) and os.path.getsize(seg) > 1024:
1020
+ if resume and _seg_usable(seg, int(f1) - int(f0)):
609
1021
  print(" skip 段 %d-%d(已存在)" % (f0, f1))
610
1022
  continue
611
- todo.append((tl_path, scenes_path, script_path, f0, f1, seg, crf, preset))
1023
+ if os.path.exists(seg):
1024
+ # 存在但不可用(半截 / 时长不符):删掉重渲,别让它混进拼接
1025
+ print(" [WARN] 段 %d-%d 已存在但帧数/时长对不上,重渲(%d 字节)"
1026
+ % (f0, f1, os.path.getsize(seg)))
1027
+ try:
1028
+ os.remove(seg)
1029
+ except OSError:
1030
+ pass
1031
+ part = os.path.join(seg_dir, "_part_" + os.path.basename(seg))
1032
+ todo.append((tl_path, scenes_path, script_path, f0, f1, seg, crf, preset, part))
612
1033
 
613
1034
  # 旧计划留下的分块不会参与拼接,但会一直占盘:说出来,不静默
614
1035
  stale = [n for n in os.listdir(seg_dir)
@@ -666,13 +1087,16 @@ def _st_frames_web(tl, work, script, jobs=1, resume=True):
666
1087
  if abs(float(page_fps) - float(tl["fps"])) > 1e-6:
667
1088
  raise RuntimeError(
668
1089
  "页面 FPS=%s 与 timeline fps=%s 不一致。浏览器逐帧渲染的帧号就是 f/FPS,"
669
- "两边必须同帧率:改页面 window.FPS,或重新 --stage layout。"
670
- % (page_fps, tl["fps"]))
1090
+ "两边必须同帧率:把页面里的 window.FPS 改成 %s(或重新 --stage layout 生成"
1091
+ "与页面一致的 timeline)。\n %s"
1092
+ % (page_fps, tl["fps"], tl["fps"], _web_where(entry)))
671
1093
  if abs(float(page_dur) - float(tl["total"])) > 0.05:
672
1094
  raise RuntimeError(
673
1095
  "页面 DURATION=%.3fs 与 timeline 总时长 %.3fs 不一致(差 %.3fs)。\n"
674
- " 页面里的时长必须等于 timeline.total;跑 --stage layout 后把新的 total 写进页面。"
675
- % (float(page_dur), float(tl["total"]), abs(float(page_dur) - float(tl["total"]))))
1096
+ " 页面里的时长必须等于 timeline.total;把页面里的 window.DURATION 改成 %.3f"
1097
+ "(跑过新的 --stage layout 之后,取 timeline.json 的 total)。\n %s"
1098
+ % (float(page_dur), float(tl["total"]), abs(float(page_dur) - float(tl["total"])),
1099
+ float(tl["total"]), _web_where(entry)))
676
1100
 
677
1101
  out = we.render_full(entry, silent, float(tl["total"]),
678
1102
  workers=max(1, int(jobs)),
@@ -699,8 +1123,99 @@ def st_mux(silent, audio, out, script):
699
1123
  return out
700
1124
 
701
1125
 
1126
+ def _qa_gates(lg, rep, deep, tl, work):
1127
+ """把 qa_report / 深检的结论拆成逐条门禁记进账本(pass / fail / skipped)。
1128
+
1129
+ 为什么拆:只有一个"质检通过/失败"的总结论,读报告的人看不出"哪几项其实没执行"。
1130
+ 这里每条都带实测值,skipped 必须写明"为什么没执行"。
1131
+ """
1132
+ pr = rep.get("probe") or {}
1133
+ streams = pr.get("streams") or []
1134
+ v = next((s for s in streams if s.get("codec_type") == "video"), {})
1135
+ a = next((s for s in streams if s.get("codec_type") == "audio"), None)
1136
+ fmt = pr.get("format") or {}
1137
+ W, H, FPS = K.W, K.H, K.FPS
1138
+
1139
+ # ---- G4 容器:分辨率 / 帧率 / 音轨 / 时长 ----
1140
+ try:
1141
+ rate = str(v.get("r_frame_rate") or "")
1142
+ num, _, den = rate.partition("/")
1143
+ got_fps = (float(num) / float(den or 1)) if num and den else 0.0
1144
+ except Exception: # noqa: BLE001
1145
+ got_fps = 0.0
1146
+ dur = float(fmt.get("duration") or 0)
1147
+ want_dur = float(tl["frames"]) / float(tl["fps"])
1148
+ detail = "%sx%s@%.3g / %s%s / %.2fs" % (
1149
+ v.get("width"), v.get("height"), got_fps,
1150
+ ("h264" if v.get("codec_name") == "h264" else str(v.get("codec_name") or "?")),
1151
+ ("+aac" if a else "(无音轨)"),
1152
+ dur)
1153
+ ok4 = (v.get("width") == W and v.get("height") == H
1154
+ and abs(got_fps - float(FPS)) <= 0.05 and a is not None
1155
+ and abs(dur - want_dur) <= 0.5)
1156
+ (lg.ok if ok4 else lg.fail)("G4", "容器层(分辨率/帧率/音轨/时长)", detail)
1157
+
1158
+ # ---- G5 响度 / 真峰 ----
1159
+ lo = rep.get("loudness") or {}
1160
+ if "I" in lo and "TP" in lo:
1161
+ i_l, tp = float(lo["I"]), float(lo["TP"])
1162
+ ok5 = (-19 <= i_l <= -12) and tp <= -1.0
1163
+ (lg.ok if ok5 else lg.fail)("G5", "响度 / 真峰(-16 LUFS / ≤ -1 dBTP)",
1164
+ "%.1f LUFS · %.1f dBTP" % (i_l, tp))
1165
+ else:
1166
+ lg.skip("G5", "响度 / 真峰", "ebur128 没测到 %s" % ("I" if "I" not in lo else "TP"))
1167
+
1168
+ # ---- G6 编码锐度比 ----
1169
+ q = rep.get("quality")
1170
+ if q and "ratio" in q:
1171
+ (lg.ok if q["ratio"] >= 0.85 else lg.fail)(
1172
+ "G6", "编码锐度比(文字发虚)", "%.3f(阈值 0.85)" % q["ratio"])
1173
+ else:
1174
+ why = "没装 cv2" if K._cv2() is None else "没有参考帧(scenes.py 缺失 / web 引擎)"
1175
+ lg.skip("G6", "编码锐度比(文字发虚)", why + ",本次没查")
1176
+
1177
+ # ---- G7 字幕条数(静默丢字幕的唯一检查点)----
1178
+ srt = os.path.join(str(work), "sub.srt")
1179
+ got = 0
1180
+ if os.path.exists(srt):
1181
+ got = len([b for b in open(srt, encoding="utf-8").read().strip().split("\n\n") if b.strip()])
1182
+ want = len(tl["scenes"])
1183
+ if got:
1184
+ (lg.ok if got == want else lg.fail)("G7", "字幕条数 = 剧本句数",
1185
+ "%d / %d" % (got, want))
1186
+ else:
1187
+ lg.skip("G7", "字幕条数 = 剧本句数", "没找到 sub.srt")
1188
+
1189
+ # ---- G8 黑帧 ----
1190
+ black_msgs = [w for w in (rep.get("warnings") or []) if "黑帧" in w]
1191
+ if black_msgs:
1192
+ # qa_report 只在"首尾之外有长黑帧"时写 warning,本身是 warning 级
1193
+ lg.skip("G8", "首尾之外无长黑帧", "检测到:" + black_msgs[0])
1194
+ else:
1195
+ lg.ok("G8", "首尾之外无长黑帧", "0 段")
1196
+
1197
+ # ---- G9 帧级深检(闪烁 / 空窗 / 音画同步)----
1198
+ if deep is None:
1199
+ lg.skip("G9", "帧级深检(闪烁/空窗/同步)", "没有 qa_frames.py 或深检失败")
1200
+ else:
1201
+ nf = len(deep.get("flicker") or [])
1202
+ nb = len(deep.get("black_windows") or [])
1203
+ ns = len(deep.get("sync") or [])
1204
+ judged = (deep.get("sync_info") or {}).get("judged")
1205
+ sync_note = ("同步查了 %s 条" % judged) if judged is not None else "同步未判(没写 visual_at)"
1206
+ ok9 = nf == 0 and nb == 0 and ns == 0
1207
+ detail9 = "闪烁 %d · 空窗 %d · 同步超差 %d(%s)" % (nf, nb, ns, sync_note)
1208
+ if ok9 and not judged and os.environ.get("QA_REQUIRE_SYNC"):
1209
+ # 与 SKILL 的 QA_REQUIRE_SYNC 同义:要求"真的查过",而不是"没报错"
1210
+ lg.fail("G9", "帧级深检(闪烁/空窗/同步超差)",
1211
+ detail9 + ";QA_REQUIRE_SYNC=1 要求同步必须被判过")
1212
+ else:
1213
+ (lg.ok if ok9 else lg.fail)("G9", "帧级深检(闪烁/空窗/同步超差)", detail9)
1214
+
1215
+
702
1216
  def st_qa(film, tl, work, scenes_mod=None, script=None):
703
1217
  print("== QA")
1218
+ th = _theme_for(script) if script is not None else None
704
1219
  shots, refs = [], [] # refs: [(frac 下标, PIL 原图)]
705
1220
  fracs = (0.08, 0.3, 0.55, 0.78)
706
1221
  ref_script = dict(script) if script is not None else None
@@ -723,7 +1238,8 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
723
1238
  try:
724
1239
  fr = scenes_mod.draw_frame(s, sec - s["start"], s["dur"], sec, ref_script,
725
1240
  int(round(sec * tl["fps"])))
726
- fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, ref_script.get("sub_max_hold")))
1241
+ fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, ref_script.get("sub_max_hold")),
1242
+ theme=th)
727
1243
  refs.append((k, fr))
728
1244
  except Exception as e: # noqa: BLE001
729
1245
  print(" [WARN] 参考帧渲染失败:", e)
@@ -739,7 +1255,7 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
739
1255
  print(" [WARN] 没有场景函数参考帧(web 引擎或 scenes.py 缺失),锐度比门禁本次跳过")
740
1256
  rep = K.qa_report(film, expect_frames=tl["frames"], ref_img=ref_img,
741
1257
  ref_sec=ref_sec, expected_subtitles=len(tl["scenes"]),
742
- notes=os.path.join(work, "sub.srt"))
1258
+ notes=os.path.join(work, "sub.srt"), theme=th)
743
1259
  pr = rep["probe"]
744
1260
  f = pr.get("format", {})
745
1261
  print(" 时长 %.2fs 大小 %.1f MB 码率 %.2f Mbps" % (
@@ -784,6 +1300,9 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
784
1300
  print(" -", e)
785
1301
  else:
786
1302
  print(" [PASS] 质检通过")
1303
+ lg = _gate()
1304
+ if lg is not None:
1305
+ _qa_gates(lg, rep, deep, tl, work)
787
1306
  return errors
788
1307
 
789
1308
 
@@ -819,6 +1338,7 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
819
1338
  print("== Stills")
820
1339
  script = dict(script)
821
1340
  script["_total"] = tl["total"]
1341
+ script.setdefault("_style", resolve_style(script))
822
1342
  outdir = os.path.join(work, "stills")
823
1343
  os.makedirs(outdir, exist_ok=True)
824
1344
  by_id = {s["id"]: s for s in tl["scenes"]}
@@ -844,7 +1364,8 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
844
1364
  if tuple(fr.size) != (K.W, K.H):
845
1365
  raise RuntimeError("场景 %s 在 t=%.2fs 返回 %dx%d,期望 %dx%d(stills 与 FrameSink 一样严格)"
846
1366
  % (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")))
1367
+ fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, script.get("sub_max_hold")),
1368
+ theme=_theme_for(script))
848
1369
  p = os.path.join(outdir, "%s_%05.1f.png" % (sid, float(lt)))
849
1370
  fr.save(p)
850
1371
  made.append(p)
@@ -856,6 +1377,103 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
856
1377
  return made
857
1378
 
858
1379
 
1380
+ def _reload_ledger_from_disk(work, ledger):
1381
+ """把 work/gates.json 里已有的门禁条目搬回内存账本(accept 这类独立进程用)。
1382
+
1383
+ 只搬**内容指纹一致**的条目:指纹不同说明剧本/场景/引擎已经变过,那些结论属于
1384
+ 上一版内容,搬进来等于拿旧结论冒充本版(比丢条目更糟)。搬不回来时明确说一句,
1385
+ 让用户知道报告里的门禁矩阵为什么是空的(该重跑 --stage all)。
1386
+ """
1387
+ p = os.path.join(str(work), "gates.json")
1388
+ if not os.path.exists(p):
1389
+ return 0
1390
+ try:
1391
+ with open(p, encoding="utf-8") as fh:
1392
+ d = json.load(fh)
1393
+ except (OSError, json.JSONDecodeError) as e:
1394
+ print(" [WARN] 旧账本读不了(%s):报告里只会有本次的条目" % e)
1395
+ return 0
1396
+ if not isinstance(d, dict):
1397
+ return 0
1398
+ if str(d.get("contentHash") or "") != str(getattr(ledger, "content_hash", "") or ""):
1399
+ print(" [WARN] 旧账本的内容指纹与当前不一致(剧本/场景/引擎改过),"
1400
+ "不搬旧门禁结论:交付前请重跑 --stage qa")
1401
+ return 0
1402
+ n = 0
1403
+ for it in d.get("items") or []:
1404
+ if not isinstance(it, dict) or not it.get("id"):
1405
+ continue
1406
+ try:
1407
+ ledger.add(str(it["id"]), str(it.get("title") or ""),
1408
+ str(it.get("status") or ""), str(it.get("detail") or ""),
1409
+ at=it.get("at"))
1410
+ n += 1
1411
+ except (ValueError, TypeError) as e:
1412
+ print(" [WARN] 旧账本条目 %r 搬不回来:%s" % (it.get("id"), e))
1413
+ for b in d.get("bypassed") or []:
1414
+ if isinstance(b, dict) and b.get("what"):
1415
+ ledger.bypassed.append(dict(what=str(b["what"]), why=str(b.get("why") or ""),
1416
+ at=float(b.get("at") or time.time())))
1417
+ return n
1418
+
1419
+
1420
+ def _report_path(a):
1421
+ """QC 报告的唯一落点:**站在成片旁边**(--out 的目录),不猜 work 的兄弟目录。
1422
+
1423
+ v0.7.2 修:accept 分支以前用 dirname(abspath(work))/out/QC-REPORT.md,而 all/mux/qa
1424
+ 用 dirname(abspath(out))。--out 指到任何非 out/ 的目录(deliver/、中文工作区/成品/…)
1425
+ 时,签核更新的是另一份没人交付的文件,交付出去的那份仍是"未签核"——正好打穿
1426
+ "报告是交付凭据"这条承诺。两处必须走同一个函数。
1427
+ """
1428
+ return os.path.join(os.path.dirname(os.path.abspath(str(a.out))), "QC-REPORT.md")
1429
+
1430
+
1431
+ # 会写交付账本(work/gates.json + QC-REPORT.md)的阶段。中间产物阶段
1432
+ # (tts/layout/cues/score/sfx/audio)只做自己的事,不去覆盖上一轮完整跑出来的
1433
+ # 交付报告——否则 `--stage layout` 一次就会把「全片通过」的报告改写成「未判定」。
1434
+ _PERSIST_STAGES = ("style", "frames", "mux", "qa", "stills", "all", "accept")
1435
+
1436
+
1437
+ def _finish_gates(a, persist=True):
1438
+ """一次运行收尾:落 gates.json + 写 QC-REPORT.md(在成片旁边)+ 统一退出码。
1439
+
1440
+ persist=False:只打印本次门禁汇总与退出码,不动磁盘上的交付账本(中间阶段用)。
1441
+ """
1442
+ lg = _LEDGER
1443
+ if lg is None:
1444
+ return 0
1445
+ errs = lg.exit_errors(strict=bool(getattr(a, "strict_gates", False)))
1446
+ gj = rep = ""
1447
+ if persist:
1448
+ # 交付账本只在交付阶段写;中间阶段(layout 等)写一次就会把上一轮
1449
+ # 「pass 8 / skipped 1」的完整报告换成「只有本阶段一条」的半成品。
1450
+ try:
1451
+ gj = lg.write_json()
1452
+ except Exception as e: # noqa: BLE001
1453
+ print("[WARN] gates.json 写入失败:", e)
1454
+ try:
1455
+ rep = GT.write_report(a.work, _report_path(a))
1456
+ except Exception as e: # noqa: BLE001
1457
+ print("[WARN] QC 报告写入失败:", e)
1458
+ c = lg.counts()
1459
+ print("")
1460
+ print("== 门禁汇总:pass %d · fail %d · skipped %d%s"
1461
+ % (c[GT.OK], c[GT.FAIL], c[GT.SKIP],
1462
+ "(--strict-gates:skipped 也算失败)" if getattr(a, "strict_gates", False) else ""))
1463
+ if gj:
1464
+ print(" 账本:", gj)
1465
+ if rep:
1466
+ print(" 报告:", rep, " <- 交付时把这个文件一起给出")
1467
+ if c[GT.SKIP]:
1468
+ print(" 注意:有 %d 项本次**没有执行**(skipped != 通过)" % c[GT.SKIP])
1469
+ if errs:
1470
+ print(" [FAIL]")
1471
+ for e in errs:
1472
+ print(" -", e)
1473
+ return 1
1474
+ return 0
1475
+
1476
+
859
1477
  # ---------------------------------------------------------------- main
860
1478
  def main():
861
1479
  _setup_stdio()
@@ -872,8 +1490,26 @@ def main():
872
1490
  ap.add_argument("--force-tts", action="store_true", help="丢弃 TTS 缓存重新合成")
873
1491
  ap.add_argument("--force-music", action="store_true", help="丢弃配乐重出(score 阶段)")
874
1492
  ap.add_argument("--stills", default="", help="stills 阶段用:逗号分隔的 id:秒")
1493
+ ap.add_argument("--gate", default="", choices=("",) + GT.GATE_ORDER,
1494
+ help="accept 阶段用:要签核哪一道闸(%s)" % " / ".join(GT.GATE_ORDER))
1495
+ ap.add_argument("--verdict", default="approved", choices=("approved", "rejected"),
1496
+ help="accept 阶段用:签核结论(rejected 视为没签)")
1497
+ ap.add_argument("--note", default="", help="accept 阶段用:签核备注(写进报告)")
1498
+ ap.add_argument("--by", default="", help="accept 阶段用:签核人(缺省 user)")
1499
+ ap.add_argument("--strict-gates", action="store_true",
1500
+ help="把 skipped 门禁也算失败(对齐 QA_REQUIRE_SYNC 语义)")
1501
+ ap.add_argument("--strict-stale", action="store_true",
1502
+ help="上游产物过时算失败(默认只告警)")
1503
+ ap.add_argument("--allow-stale", action="store_true",
1504
+ help="过时也照跑(会在 QC 报告里留痕)")
1505
+ ap.add_argument("--no-gate", action="store_true",
1506
+ help="跳过人工签核检查(会在 QC 报告里留痕,不算通过)")
1507
+ ap.add_argument("--cost", action="store_true",
1508
+ help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
875
1509
  a = ap.parse_args()
876
1510
 
1511
+ # work/ 本来就不存在 = 这一跑自己产出全部中间物,别把"我还没跑到"当成"产物过时"。
1512
+ work_pre_exists = os.path.isdir(a.work)
877
1513
  try:
878
1514
  os.makedirs(a.work, exist_ok=True)
879
1515
  except OSError as e:
@@ -884,6 +1520,117 @@ def main():
884
1520
  raise SystemExit("engine 只能是 pil 或 web,收到 %r" % engine)
885
1521
  script["_engine"] = engine
886
1522
  script["_scenes"] = str(Path(a.scenes).resolve())
1523
+
1524
+ # ---- 门禁账本(v0.6.0)----
1525
+ global _LEDGER
1526
+ # engine=web 时把入口页实际引用的同目录页面脚本一起纳入内容指纹:web 的画面逻辑在
1527
+ # web/*.js 里,不在 scenes.py 里——不纳入的话改动画后旧签核仍然"有效",正好违背
1528
+ # v0.6「签核绑内容指纹,改了画面代码旧签核自动失效」的承诺。
1529
+ web_entry = ""
1530
+ if engine == "web":
1531
+ try:
1532
+ web_entry = _web_entry(script) # 入口页不存在时这里会 SystemExit(本来就该早失败)
1533
+ except SystemExit:
1534
+ web_entry = "" # 页面还没建:交给后面的 stage 报错,账本先照建
1535
+ _LEDGER = GT.GateLedger(a.work, script, scenes_path=a.scenes,
1536
+ script_path=a.script, engine=engine,
1537
+ web_entry=web_entry)
1538
+ if a.no_gate:
1539
+ _LEDGER.bypass("人工签核检查(--no-gate)", "本条不算通过,只记录")
1540
+ print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
1541
+
1542
+ def _sources():
1543
+ """源头输入:剧本与 scenes.py(改它们等于改了所有下游的依据)。"""
1544
+ return {"script": a.script, "scenes": a.scenes}
1545
+
1546
+ # 本次命令行**计划要跑**的 stage 集合:--stage all 展开成 10 个,单 stage 就是它自己。
1547
+ # 用途见 stagewatch.check 的 pending:本阶段马上要用新输入重算,那种"输入变了"不是过时。
1548
+ _ALL_ORDER = ("style", "tts", "layout", "cues", "score", "sfx", "audio",
1549
+ "frames", "mux", "qa")
1550
+
1551
+ def _planned(stage_name):
1552
+ if stage_name == "all":
1553
+ return list(_ALL_ORDER)
1554
+ return [stage_name] if stage_name in STAGES else []
1555
+
1556
+ def _stage_begin(name):
1557
+ """stage 开始时查新鲜度并计时。返回 t0。
1558
+
1559
+ report() 自己负责打印 "[stale] <stage>: <原因>" 与"最小重跑"两行
1560
+ (v0.7.2 修:以前只调不接,用户只看到"最小重跑"不知道是哪个阶段、
1561
+ 为什么过时,得上 --stage explain 才知道)。
1562
+ --allow-stale 也要调用:它承诺的是"放行并留痕"(QC 报告里记一条 skipped),
1563
+ 直接跳过检查连痕都没有——那就成了静默。
1564
+ """
1565
+ if name:
1566
+ 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))
1571
+ if errs:
1572
+ # --strict-stale 的承诺是"上游产物过时算失败"。stagewatch.report 只把
1573
+ # 问题放进返回值(并往账本记一条 fail),它自己不抛异常——所以必须在这里
1574
+ # 真的失败。以前丢弃了返回值,于是 --stage layout --strict-stale 报完过时
1575
+ # 照跑、退出码 0,help 文本与行为对不上。
1576
+ #
1577
+ # 选择"立刻退出"而不是"跑完再在收尾处报":过时的输入会把旧产物掺进
1578
+ # 新结果(比如拿旧 cues.json 渲新时间轴的画面),跑完再报既已浪费渲染
1579
+ # 时间,产出的东西也不能用。--stage all 同理:一发现就停。
1580
+ # 不写 work/gates.json:那是**交付账本**(上一轮 --stage all 的完整
1581
+ # 结论)。中间阶段一次失败就把它覆盖成单条 fail,用户手里那份
1582
+ # QC-REPORT.md 会变成「未判定」,比留证据代价更大。失败证据在
1583
+ # stdout 与退出码里,够定位。
1584
+ # 原因行 report() 已经逐条打过了,这里只补"为什么停下 + 怎么办",
1585
+ # 不再把 errs 原样贴一遍。
1586
+ raise SystemExit(
1587
+ "[stale][strict] 上面 %d 处上游产物过时 -> 本阶段不跑,直接失败。\n"
1588
+ " (--strict-stale 下过时=失败;继续跑会把旧产物掺进新结果,"
1589
+ "白渲一遍还是错的。)\n"
1590
+ " 怎么办:\n"
1591
+ " 1) 照着每条下面的『最小重跑』先补上游,再重跑当前阶段\n"
1592
+ " 2) 确认旧产物可以接受:加 --allow-stale 显式放行"
1593
+ "(退出码 0,QC 报告里记 skipped)\n"
1594
+ " 3) 只想看现状不跑:python render.py --stage explain"
1595
+ % len(errs))
1596
+ return time.time()
1597
+
1598
+ def _stage_end(name, t0, **extra):
1599
+ """stage 结束时落盘:manifest 快照 + 耗时台账。"""
1600
+ if not name:
1601
+ return
1602
+ try:
1603
+ SW.snapshot(a.work, name, _sources())
1604
+ except Exception as e: # noqa: BLE001
1605
+ print(" [WARN] manifest 快照失败:", e)
1606
+ wall = time.time() - t0
1607
+ if a.cost:
1608
+ try:
1609
+ GT.append_cost(a.work, name, wall, **extra)
1610
+ except Exception as e: # noqa: BLE001
1611
+ print(" [WARN] cost.jsonl 写入失败:", e)
1612
+ print(" [time] %s 用时 %.1fs%s" % (name, wall, "(已记 work/cost.jsonl)" if a.cost else ""))
1613
+
1614
+ def _check_signoff(where):
1615
+ """交付前的签核检查:缺签只告警,--strict-gates 下算失败。"""
1616
+ if a.no_gate:
1617
+ return
1618
+ state, stale = GT.signoff_state(a.work, _LEDGER.content_hash)
1619
+ bad = [g for g, v in state.items() if v["status"] == GT.SKIP]
1620
+ rejected = [g for g, v in state.items() if v["status"] == GT.FAIL]
1621
+ for g in rejected:
1622
+ _LEDGER.fail("signoff:" + g, "人工签核:" + GT.GATES[g], state[g]["detail"])
1623
+ if bad:
1624
+ _LEDGER.skip("signoff", "人工签核(--stage accept 签核)",
1625
+ "未签/已失效:%s%s"
1626
+ % ("、".join(bad), ("(内容已变:%s)" % "、".join(stale)) if stale else ""))
1627
+ print(" [WARN] 人工签核未完成:%s" % "、".join(bad))
1628
+ print(" 交付前请 --stage accept --gate <名字>;"
1629
+ "--strict-gates 会把它算作失败")
1630
+ for g in bad:
1631
+ print(" %-8s %s" % (g, GT.GATES[g]))
1632
+ elif not rejected:
1633
+ _LEDGER.ok("signoff", "人工签核", "%d/4 道闸已签" % len(GT.GATE_ORDER))
887
1634
  if a.force_music:
888
1635
  plan, _why = K.music_plan(script, has_music_file=os.path.exists(os.path.join(a.work, "music.wav")))
889
1636
  if plan in ("off", "bed", "file"):
@@ -924,48 +1671,162 @@ def main():
924
1671
  return mod
925
1672
 
926
1673
  stage = a.stage
1674
+
1675
+ # ---- explain:只报"哪些阶段过时了",不跑任何 stage ----
1676
+ if stage == "explain":
1677
+ rows = SW.explain(a.work, sources=_sources())
1678
+ bad = 0
1679
+ for r in rows:
1680
+ if r["issues"]:
1681
+ bad += 1
1682
+ print("%s%-8s %s" % ("!! " if r["issues"] else "OK ", r["stage"],
1683
+ r["at"] or "(无记录)"))
1684
+ for it in r["issues"]:
1685
+ print(" -", it["reason"])
1686
+ if it.get("rerun"):
1687
+ print(" 最小重跑:", it["rerun"])
1688
+ print()
1689
+ if bad:
1690
+ print("%d 个阶段过时。默认只告警;--strict-stale 让它失败。" % bad)
1691
+ return 1 if a.strict_stale else 0
1692
+ print("没有发现过时的阶段(按 work/manifest.json 判断)。")
1693
+ return 0
1694
+
1695
+ # ---- accept:人工签核一道闸(写 work/signoff.json,绑内容指纹)----
1696
+ if stage == "accept":
1697
+ if not a.gate:
1698
+ raise SystemExit("[accept] 必须指定要签哪一道闸:--gate %s"
1699
+ % " / ".join(GT.GATE_ORDER))
1700
+ if a.verdict == "approved":
1701
+ # 签核前必须能证明"真的看过了":证据产物不在就拒绝签(不然签核是橡皮图章)。
1702
+ # 证据可能在 work/(渲染产物),也可能在项目根(FACTS.md 是人写的输入),两处都找。
1703
+ proj = os.path.dirname(os.path.abspath(a.script))
1704
+ want = {"style": [os.path.join(a.work, "style.json"),
1705
+ os.path.join(a.work, "voice_plan.json")],
1706
+ "stills": [os.path.join(a.work, "qa", "sheet.png")],
1707
+ "facts": [os.path.join(proj, "FACTS.md"), os.path.join(a.work, "FACTS.md")],
1708
+ "release": [a.out]}.get(a.gate, [])
1709
+ # 每一组是"多选一":任一条路径存在就算有证据。
1710
+ missing = None
1711
+ if want and not any(os.path.exists(str(w)) for w in want):
1712
+ missing = "、".join(os.path.relpath(w, proj) if os.path.isabs(w) else w
1713
+ for w in want)
1714
+ if missing:
1715
+ raise SystemExit("[accept] 证据不存在,拒绝签核:先跑出要看的东西(缺 %s)"
1716
+ % missing)
1717
+ rec = GT.write_signoff(a.work, a.gate, _LEDGER.content_hash,
1718
+ verdict=a.verdict, by=a.by or "user",
1719
+ note=a.note,
1720
+ artifacts=[os.path.join(a.work, "gates.json")])
1721
+ print("[accept] %s = %s(内容指纹 %s,%s)"
1722
+ % (a.gate, rec["verdict"], _LEDGER.content_hash, rec["atText"]))
1723
+ if a.verdict == "rejected":
1724
+ print("[accept] 已记「否决」(不算签核通过);改了内容后需重新签。")
1725
+ # accept 是**独立进程**:本次账本是空的。直接 write_json 会把上一次 all/mux/qa
1726
+ # 记的 8 条门禁结论整个覆盖掉(报告变成 "pass 0 · skipped 1",交付凭据被打穿)。
1727
+ # 先把磁盘上的旧账本搬回来(内容指纹一致才搬),再追加签核状态。
1728
+ _reload_ledger_from_disk(a.work, _LEDGER)
1729
+ # 签核状态进账本:否则报告里的"人工签核"一行仍是上一次 all/mux 收尾时记的
1730
+ # skipped,用户签完再打开报告却看不到"已签"——签核就不算兑现。
1731
+ try:
1732
+ _check_signoff(stage)
1733
+ except Exception as e: # noqa: BLE001
1734
+ print(" [WARN] 签核状态没记进账本:", e)
1735
+ try:
1736
+ _LEDGER.write_json()
1737
+ except Exception as e: # noqa: BLE001
1738
+ print(" [WARN] gates.json 写入失败:", e)
1739
+ p_rep = GT.write_report(a.work, _report_path(a))
1740
+ print("[accept] QC 报告已更新:", p_rep)
1741
+ return 0
1742
+
1743
+ if stage in ("all", "style"):
1744
+ # --stage all 也要把 style 这一行记进 manifest:以前 _nm 在 all 下是 None,
1745
+ # 而 _stage_begin/_stage_end 对 None 是空操作 -> work/style.json 写出来了、
1746
+ # manifest.json 却从来没有 style 记录。于是之后任何一次 `--stage style` 都会
1747
+ # 命中「产物在但没有记录」的判据,报成 no-manifest(--stage all 的用户 100% 踩到)。
1748
+ _t0 = _stage_begin("style")
1749
+ resolve_style(script, a.work)
1750
+ _stage_end("style", _t0, n_tts=None)
1751
+ if stage == "style":
1752
+ print("== Style")
1753
+ _print_style(script["_style"])
1754
+ _need(os.path.exists(os.path.join(a.work, "style.json")), "style.json 没写出来")
1755
+ _LEDGER.ok("style", "风格包解析", str(script["_style"].get("style")))
1756
+ _LEDGER.write_json()
1757
+ _finish_gates(a)
1758
+ return 0
1759
+
927
1760
  if stage in ("all", "tts"):
1761
+ _t0 = _stage_begin("tts")
928
1762
  lines = st_tts(script, a.work, force=a.force_tts)
1763
+ _stage_end("tts", _t0, n_tts=len(lines) if isinstance(lines, list) else None)
1764
+ # 既有行为:单跑 tts 也会排一次轴(tts.json 的时长信息要用),这里一并计时。
1765
+ _t0 = _stage_begin("layout")
929
1766
  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)
1767
+ _stage_end("layout", _t0)
1768
+ if stage == "layout":
1769
+ _t0 = _stage_begin("layout")
1770
+ _need(os.path.exists(tts_p), "没有 tts.json,先跑 --stage tts")
1771
+ tl = st_layout(script, a.work)
1772
+ _stage_end("layout", _t0)
934
1773
  if stage in ("all", "cues"):
935
1774
  tl = tl if stage == "all" else ensure_tl()
936
- st_cues(script, a.work, tl, engine=engine)
1775
+ _t0 = _stage_begin("cues")
1776
+ cues = st_cues(script, a.work, tl, engine=engine)
1777
+ _stage_end("cues", _t0,
1778
+ n_cues=(len((cues or {}).get("cues") or []) if isinstance(cues, dict) else None))
937
1779
  if stage in ("all", "score"):
938
1780
  tl = tl if stage == "all" else ensure_tl()
1781
+ _t0 = _stage_begin("score")
939
1782
  st_score(script, a.work, tl)
1783
+ _stage_end("score", _t0)
940
1784
  if stage in ("all", "sfx"):
941
1785
  tl = tl if stage == "all" else ensure_tl()
1786
+ _t0 = _stage_begin("sfx")
942
1787
  st_sfx(script, a.work, tl)
1788
+ _stage_end("sfx", _t0)
943
1789
  if stage in ("all", "audio"):
944
1790
  tl = tl if stage == "all" else ensure_tl()
945
1791
  _need(all(s.get("path") for s in tl["scenes"]), "timeline 里没有旁白路径,先跑 --stage tts")
946
1792
  missing = [s.get("id") for s in tl["scenes"] if not os.path.exists(str(s.get("path") or ""))]
947
1793
  _need(not missing, "旁白文件不存在:%s(tts 目录被删/挪过?重新跑 --stage tts)"
948
1794
  % (missing[:6],))
1795
+ _t0 = _stage_begin("audio")
949
1796
  st_audio(tl, a.work, script)
1797
+ _stage_end("audio", _t0)
950
1798
 
951
1799
  if stage == "stills":
952
1800
  # 母版预览是"上手第一件事",可它需要 timeline。以前干净目录直接 SystemExit,
953
1801
  # 而文档三步上手就把它排在第 4 步(照抄必失败)。这里自动补前置:
954
1802
  # 缺 tts.json 就真合成一次旁白(要联网),缺 timeline 就排一次轴——
955
1803
  # 但每一步都**明说**自己在补什么,不偷偷跑。
1804
+ # 自动补的这几步也要 _stage_begin/_stage_end:否则 manifest 里只有 stills 一条记录,
1805
+ # 下次 --stage tts 会看到 tts.json 在、账本里没有 tts 记录 -> 误报「接手旧 work」。
956
1806
  if not os.path.exists(tts_p):
957
1807
  print("[stills] 没有 tts.json,自动先跑 --stage tts(需要联网合成旁白)")
1808
+ _t0 = _stage_begin("tts")
958
1809
  lines = st_tts(script, a.work, force=a.force_tts)
1810
+ _stage_end("tts", _t0, n_tts=len(lines) if isinstance(lines, list) else None)
1811
+ _t0 = _stage_begin("layout")
959
1812
  tl_tmp = st_layout(script, a.work, lines)
1813
+ _stage_end("layout", _t0)
960
1814
  tl = tl_tmp
961
1815
  elif not os.path.exists(tl_p):
962
1816
  print("[stills] 没有 timeline.json,自动先跑 --stage layout")
1817
+ _t0 = _stage_begin("layout")
963
1818
  tl = st_layout(script, a.work)
1819
+ _stage_end("layout", _t0)
964
1820
  else:
965
1821
  tl = ensure_tl()
966
1822
  mod = load_scenes()
967
1823
  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)
1824
+ _t0 = _stage_begin("stills")
1825
+ made = st_stills(tl, a.work, mod, script, specs)
1826
+ _stage_end("stills", _t0)
1827
+ _LEDGER.add("stills", "母版静帧已产出", GT.OK if made else GT.FAIL,
1828
+ "%d 张(要人看过才算过:--stage accept --gate stills)" % len(made or []))
1829
+ _finish_gates(a)
969
1830
  return 0
970
1831
 
971
1832
  if stage == "qa":
@@ -973,8 +1834,12 @@ def main():
973
1834
  tl = ensure_tl()
974
1835
  _need(os.path.exists(a.out), "没有成片 %s,先跑 --stage mux" % a.out)
975
1836
  mod = load_scenes(strict=False) if engine == "pil" else None
1837
+ _t0 = _stage_begin("qa")
976
1838
  errs = st_qa(a.out, tl, a.work, scenes_mod=mod, script=script)
977
- return 1 if errs else 0
1839
+ _stage_end("qa", _t0)
1840
+ _check_signoff("qa")
1841
+ rc = _finish_gates(a)
1842
+ return 1 if (errs or rc) else 0
978
1843
 
979
1844
  if stage in ("all", "frames", "mux"):
980
1845
  tl = tl if stage == "all" else ensure_tl()
@@ -988,24 +1853,56 @@ def main():
988
1853
  _need(os.path.exists(audio), "混音阶段异常:audio.wav 未生成")
989
1854
  if engine == "pil":
990
1855
  mod = load_scenes()
1856
+ _t0 = _stage_begin("frames")
991
1857
  silent = st_frames(tl, a.work, mod, script, jobs=a.jobs, resume=a.resume,
992
1858
  engine=engine)
1859
+ _stage_end("frames", _t0, frames=int(tl["frames"]))
1860
+ if stage == "frames":
1861
+ _LEDGER.ok("frames", "逐帧渲染", "%d 帧(%s)" % (int(tl["frames"]), engine))
1862
+ _finish_gates(a)
1863
+ return 0 if not _LEDGER.fails() else 1
993
1864
  if stage in ("all", "mux"):
994
1865
  # mux 只需要 silent.mp4 + audio.wav:不加载 scenes.py(缺场景模块也能合成)
995
1866
  _need(os.path.exists(silent), "没有 silent.mp4,先跑 --stage frames")
996
1867
  _need(os.path.exists(audio), "没有 audio.wav,先跑 --stage audio")
1868
+ _t0 = _stage_begin("mux")
997
1869
  film = st_mux(silent, audio, a.out, script)
1870
+ _stage_end("mux", _t0)
1871
+ _check_signoff("mux")
1872
+ if stage == "mux":
1873
+ _LEDGER.ok("mux", "合成(视频流 copy + loudnorm)", a.out)
1874
+ _finish_gates(a)
1875
+ return 0 if not _LEDGER.fails() else 1
998
1876
  if stage == "all":
1877
+ _t0 = _stage_begin("qa")
999
1878
  errs = st_qa(film, tl, a.work, scenes_mod=mod, script=script)
1000
- if errs:
1879
+ _stage_end("qa", _t0)
1880
+ rc = _finish_gates(a)
1881
+ if errs or rc:
1001
1882
  print("\n[质检未过] 成片已生成但没有通过门禁,按上面 [FAIL] 逐条修。")
1002
1883
  return 1
1003
- return 0
1884
+ return 0
1885
+ # 所有 stage 统一收尾:落 gates.json + QC-REPORT.md,并按门禁结果给退出码。
1886
+ # v0.7.2 修:以前只有 style/frames/mux/qa 这几条分支自己调 _finish_gates,其余
1887
+ # (tts/layout/cues/score/sfx/audio/stills)落在这行 return 0——于是 ledger 里
1888
+ # 记的 fail 传不到退出码,"门禁失败 -> 非 0"这个承诺按 stage 不同行为不一样。
1889
+ # 默认路径不受影响:没有 fail(且没开 --strict-gates)时 _finish_gates 返回 0。
1890
+ rc = _finish_gates(a, persist=(stage in _PERSIST_STAGES))
1891
+ return 1 if (rc or _LEDGER.fails()) else 0
1004
1892
 
1005
1893
 
1006
1894
  if __name__ == "__main__":
1007
1895
  try:
1008
1896
  sys.exit(main())
1897
+ except KeyboardInterrupt:
1898
+ # Ctrl-C:给「能不能续跑」的明确答复,而不是甩 traceback 或静默退出 130。
1899
+ # 分块渲染先写 _part_ 再改名,所以中断不会留下被 --resume 误认的半截分块;
1900
+ # 已完成的段下次加 --resume 直接复用,残留的 _part_ 会被自动清掉。
1901
+ _setup_stdio()
1902
+ print("\n[中断] 收到 Ctrl-C,本次运行已停止。", file=sys.stderr)
1903
+ print(" 已完成的产物都留在 work/ 里:分块渲染请加 --resume 续跑"
1904
+ "(半成品段不会被当成完成段复用)。", file=sys.stderr)
1905
+ sys.exit(130)
1009
1906
  except RuntimeError as e:
1010
1907
  # 领域错误(音视频时长不符 / 分块缺口 / 编码失败 / TTS 重试耗尽 …)给一句话,
1011
1908
  # 不要甩 Python traceback;要完整栈就 set RENDER_DEBUG=1。