@dsh-gis/dsh-video-studio-skill 0.6.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 (32) hide show
  1. package/README.md +41 -16
  2. package/index.js +20 -7
  3. package/package.json +4 -2
  4. package/schemas/film.schema.json +75 -4
  5. package/skills/video-studio/SKILL.md +95 -23
  6. package/skills/video-studio/assets/film.json +7 -14
  7. package/skills/video-studio/assets/scenes.py +115 -10
  8. package/skills/video-studio/assets/web/engine.js +18 -1
  9. package/skills/video-studio/assets/web/film.js +29 -4
  10. package/skills/video-studio/assets/web/index.html +5 -0
  11. package/skills/video-studio/references/audio-variation.md +158 -0
  12. package/skills/video-studio/references/audio.md +67 -5
  13. package/skills/video-studio/references/change-impact.md +1 -1
  14. package/skills/video-studio/references/quality-gates.md +1 -1
  15. package/skills/video-studio/references/web-engine.md +1 -1
  16. package/skills/video-studio/requirements.txt +2 -1
  17. package/skills/video-studio/scripts/filmcheck.py +81 -12
  18. package/skills/video-studio/scripts/filmkit.py +271 -18
  19. package/skills/video-studio/scripts/gates.py +75 -12
  20. package/skills/video-studio/scripts/qa_frames.py +109 -19
  21. package/skills/video-studio/scripts/render.py +420 -51
  22. package/skills/video-studio/scripts/scorekit.py +289 -46
  23. package/skills/video-studio/scripts/selfcheck.py +302 -2
  24. package/skills/video-studio/scripts/sfxkit.py +132 -30
  25. package/skills/video-studio/scripts/stagewatch.py +141 -31
  26. package/skills/video-studio/scripts/stylekit.py +94 -6
  27. package/skills/video-studio/scripts/voicekit.py +8 -0
  28. package/skills/video-studio/scripts/webengine.py +68 -14
  29. package/tools/audio_similarity.py +279 -0
  30. package/tools/ci_check.py +145 -34
  31. package/tools/estimate.py +133 -54
  32. package/tools/selftest_noscipy.py +70 -0
@@ -143,6 +143,26 @@ def _web_entry(script):
143
143
  return entry
144
144
 
145
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
+
146
166
  # ---------------------------------------------------------------- 装载
147
167
  def load_module(path: str, name="film_scenes"):
148
168
  """每次都用独立模块名 + 全新 module 对象,避免跨片污染(历史坑:渲染出上一部片)。"""
@@ -229,8 +249,13 @@ def resolve_style(script, work=None, verbose=True):
229
249
  return script["_style"]
230
250
  ST = _stylekit()
231
251
  plan = ST.resolve_script_style(script)
232
- # 主题体检:坏主题现在拦,不要等渲完 300 帧
233
- bad = ST.theme_lint(ST.THEMES[plan["theme_id"]])
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"]])
234
259
  if bad:
235
260
  raise SystemExit("[主题体检失败] %s:%s" % (plan["theme_id"], ";".join(bad)))
236
261
  script["_style"] = plan
@@ -439,6 +464,19 @@ def st_cues(script, work, tl, engine="pil"):
439
464
  except Exception as e: # noqa: BLE001
440
465
  raise SystemExit("scenes.py 的 cues() 钩子报错:%s" % e) from e
441
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() 钩子。")
442
480
 
443
481
  # web 引擎:从页面导出(需要浏览器)
444
482
  n_web = 0
@@ -521,21 +559,48 @@ def st_score(script, work, tl):
521
559
  style_arg = None if plan_s["legacy"] else str(mp.get("style") or plan_s["style"])
522
560
  kwargs = dict(bpm=float(mp["bpm"]), key=str(mp["key"] or "Dm"), anchors=anchors,
523
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
524
570
  # scorekit 0.5 起:style/mood + music_plan(一次把 bpm/key/mood/seed 全交出去,
525
571
  # 免得两处默认值打架)+ pace(把过碎的段落并进前一段)。
526
572
  # 旧 scorekit 没有这些 kwargs -> TypeError -> 自动退回老接口,不阻断出片。
573
+ # 四级回退:带指纹 -> 不带指纹 -> 只有 style/mood -> 最老的签名。
527
574
  pace = {"min_dur": float(plan_s["pacing"]["min_dur"]),
528
575
  "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:
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:
534
590
  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)
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))
539
604
  if report and not plan_s["legacy"]:
540
605
  print(" 配乐风格: %s / mood %s / %s BPM / %s"
541
606
  % (report.get("style") or style_arg, mp.get("mood"),
@@ -551,8 +616,82 @@ def st_score(script, work, tl):
551
616
  return out
552
617
 
553
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
+
554
688
  def st_sfx(script, work, tl):
555
- """按提示点合成音效轨 -> work/sfx.wav。没有 cues.json 就跳过。"""
689
+ """按提示点合成音效轨 -> work/sfx.wav。没有 cues.json 就跳过。
690
+
691
+ v0.7:非 legacy 时走内容派生 seed(跨片同一位置不再逐样本相同),
692
+ 并把配乐调性透传给提示音配方(不再硬编码 Dm);
693
+ 段落边界前的转场音效自动让位(不与配乐过门撞车)。
694
+ """
556
695
  print("== SFX")
557
696
  cues_path = os.path.join(work, "cues.json")
558
697
  if not os.path.exists(cues_path):
@@ -564,10 +703,12 @@ def st_sfx(script, work, tl):
564
703
  print(" cues.json 里没有提示点,跳过")
565
704
  return None
566
705
  sfx_cfg = script.get("sfx") or {}
706
+ style, content_fp, key = _sfx_seed_context(script)
567
707
  try:
568
708
  samples = bus.build_audio(total=tl["total"],
569
709
  base_db=sfx_cfg.get("base_db"),
570
- 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)
571
712
  except Exception as e: # noqa: BLE001
572
713
  # 音效轨是"增强"不是"必需":sfxkit 缺失/配方不全时不要拖死整片,
573
714
  # 但必须把原因喊出来(静默降级比报错更危险)。
@@ -576,6 +717,10 @@ def st_sfx(script, work, tl):
576
717
  if os.path.exists(stale):
577
718
  os.remove(stale)
578
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 [])
579
724
  out = os.path.join(work, "sfx.wav")
580
725
  peak = K.wav_write(out, samples)
581
726
  print(" sfx.wav %.2fs 峰值 %.3f 声道 %d" % (
@@ -672,15 +817,30 @@ def st_audio(tl, work, script):
672
817
  plan_s = resolve_style(script)
673
818
  # 风格化的 duck 深度:轻快/极简风格的配乐更"薄",让位可以浅一点;
674
819
  # 电影/史诗要压得更狠。用户显式写 music.duck_db 时永远以用户为准。
675
- duck_default = 3.0
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
676
823
  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)
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)
679
826
  samples = K.mix_film(voice, music, sfx, total=total,
680
827
  voice_gain=float(script.get("voice_gain", 1.0)),
681
- 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)),
682
832
  sfx_gain_db=float((script.get("sfx") or {}).get("mix_gain_db", 0.0)),
683
- duck_db=float(mus.get("duck_db", duck_default)))
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)))
684
844
  out = os.path.join(work, "audio.wav")
685
845
  peak = K.wav_write(out, samples)
686
846
  print(" audio.wav %.2fs 峰值 %.3f 声道 %d" % (K.wav_dur(out), peak, 2))
@@ -732,9 +892,36 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
732
892
  return sink.close()
733
893
 
734
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
+
735
922
  def _render_chunk_worker(args):
736
923
  """分块渲染工作单元(供 multiprocessing 调用;函数必须模块级可 pickle)。"""
737
- (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
738
925
  tl = json.load(open(tl_path, encoding="utf-8"))
739
926
  script = json.load(open(script_path, encoding="utf-8"))
740
927
  script["_total"] = tl["total"]
@@ -749,7 +936,12 @@ def _render_chunk_worker(args):
749
936
  print(" [WARN] 子进程重解风格失败,回退到快照里的 _style:%s" % e)
750
937
  script["_style"] = json.load(open(script_path, encoding="utf-8")).get("_style")
751
938
  mod = load_module(scenes_path, "film_scenes_chunk_%d" % f0)
752
- 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
753
945
 
754
946
 
755
947
  def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
@@ -813,13 +1005,31 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
813
1005
 
814
1006
  todo = []
815
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
816
1017
  for (f0, f1) in plan:
817
1018
  seg = os.path.join(seg_dir, "seg_%06d_%06d.mp4" % (f0, f1))
818
1019
  planned.add(os.path.basename(seg))
819
- if resume and os.path.exists(seg) and os.path.getsize(seg) > 1024:
1020
+ if resume and _seg_usable(seg, int(f1) - int(f0)):
820
1021
  print(" skip 段 %d-%d(已存在)" % (f0, f1))
821
1022
  continue
822
- 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))
823
1033
 
824
1034
  # 旧计划留下的分块不会参与拼接,但会一直占盘:说出来,不静默
825
1035
  stale = [n for n in os.listdir(seg_dir)
@@ -877,13 +1087,16 @@ def _st_frames_web(tl, work, script, jobs=1, resume=True):
877
1087
  if abs(float(page_fps) - float(tl["fps"])) > 1e-6:
878
1088
  raise RuntimeError(
879
1089
  "页面 FPS=%s 与 timeline fps=%s 不一致。浏览器逐帧渲染的帧号就是 f/FPS,"
880
- "两边必须同帧率:改页面 window.FPS,或重新 --stage layout。"
881
- % (page_fps, tl["fps"]))
1090
+ "两边必须同帧率:把页面里的 window.FPS 改成 %s(或重新 --stage layout 生成"
1091
+ "与页面一致的 timeline)。\n %s"
1092
+ % (page_fps, tl["fps"], tl["fps"], _web_where(entry)))
882
1093
  if abs(float(page_dur) - float(tl["total"])) > 0.05:
883
1094
  raise RuntimeError(
884
1095
  "页面 DURATION=%.3fs 与 timeline 总时长 %.3fs 不一致(差 %.3fs)。\n"
885
- " 页面里的时长必须等于 timeline.total;跑 --stage layout 后把新的 total 写进页面。"
886
- % (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)))
887
1100
 
888
1101
  out = we.render_full(entry, silent, float(tl["total"]),
889
1102
  workers=max(1, int(jobs)),
@@ -1164,23 +1377,84 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
1164
1377
  return made
1165
1378
 
1166
1379
 
1167
- def _finish_gates(a):
1168
- """一次运行收尾:落 gates.json + 写 out/QC-REPORT.md + 统一退出码。"""
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
+ """
1169
1442
  lg = _LEDGER
1170
1443
  if lg is None:
1171
1444
  return 0
1172
1445
  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 = ""
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)
1184
1458
  c = lg.counts()
1185
1459
  print("")
1186
1460
  print("== 门禁汇总:pass %d · fail %d · skipped %d%s"
@@ -1234,6 +1508,8 @@ def main():
1234
1508
  help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
1235
1509
  a = ap.parse_args()
1236
1510
 
1511
+ # work/ 本来就不存在 = 这一跑自己产出全部中间物,别把"我还没跑到"当成"产物过时"。
1512
+ work_pre_exists = os.path.isdir(a.work)
1237
1513
  try:
1238
1514
  os.makedirs(a.work, exist_ok=True)
1239
1515
  except OSError as e:
@@ -1247,8 +1523,18 @@ def main():
1247
1523
 
1248
1524
  # ---- 门禁账本(v0.6.0)----
1249
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 报错,账本先照建
1250
1535
  _LEDGER = GT.GateLedger(a.work, script, scenes_path=a.scenes,
1251
- script_path=a.script, engine=engine)
1536
+ script_path=a.script, engine=engine,
1537
+ web_entry=web_entry)
1252
1538
  if a.no_gate:
1253
1539
  _LEDGER.bypass("人工签核检查(--no-gate)", "本条不算通过,只记录")
1254
1540
  print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
@@ -1257,11 +1543,56 @@ def main():
1257
1543
  """源头输入:剧本与 scenes.py(改它们等于改了所有下游的依据)。"""
1258
1544
  return {"script": a.script, "scenes": a.scenes}
1259
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
+
1260
1556
  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())
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))
1265
1596
  return time.time()
1266
1597
 
1267
1598
  def _stage_end(name, t0, **extra):
@@ -1391,16 +1722,32 @@ def main():
1391
1722
  % (a.gate, rec["verdict"], _LEDGER.content_hash, rec["atText"]))
1392
1723
  if a.verdict == "rejected":
1393
1724
  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"))
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))
1396
1740
  print("[accept] QC 报告已更新:", p_rep)
1397
1741
  return 0
1398
1742
 
1399
1743
  if stage in ("all", "style"):
1400
- _nm = "style" if stage == "style" else None
1401
- _t0 = _stage_begin(_nm)
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")
1402
1749
  resolve_style(script, a.work)
1403
- _stage_end(_nm, _t0, n_tts=None)
1750
+ _stage_end("style", _t0, n_tts=None)
1404
1751
  if stage == "style":
1405
1752
  print("== Style")
1406
1753
  _print_style(script["_style"])
@@ -1454,14 +1801,22 @@ def main():
1454
1801
  # 而文档三步上手就把它排在第 4 步(照抄必失败)。这里自动补前置:
1455
1802
  # 缺 tts.json 就真合成一次旁白(要联网),缺 timeline 就排一次轴——
1456
1803
  # 但每一步都**明说**自己在补什么,不偷偷跑。
1804
+ # 自动补的这几步也要 _stage_begin/_stage_end:否则 manifest 里只有 stills 一条记录,
1805
+ # 下次 --stage tts 会看到 tts.json 在、账本里没有 tts 记录 -> 误报「接手旧 work」。
1457
1806
  if not os.path.exists(tts_p):
1458
1807
  print("[stills] 没有 tts.json,自动先跑 --stage tts(需要联网合成旁白)")
1808
+ _t0 = _stage_begin("tts")
1459
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")
1460
1812
  tl_tmp = st_layout(script, a.work, lines)
1813
+ _stage_end("layout", _t0)
1461
1814
  tl = tl_tmp
1462
1815
  elif not os.path.exists(tl_p):
1463
1816
  print("[stills] 没有 timeline.json,自动先跑 --stage layout")
1817
+ _t0 = _stage_begin("layout")
1464
1818
  tl = st_layout(script, a.work)
1819
+ _stage_end("layout", _t0)
1465
1820
  else:
1466
1821
  tl = ensure_tl()
1467
1822
  mod = load_scenes()
@@ -1527,13 +1882,27 @@ def main():
1527
1882
  print("\n[质检未过] 成片已生成但没有通过门禁,按上面 [FAIL] 逐条修。")
1528
1883
  return 1
1529
1884
  return 0
1530
- rc = _finish_gates(a) if stage == "all" else 0
1531
- return rc
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
1532
1892
 
1533
1893
 
1534
1894
  if __name__ == "__main__":
1535
1895
  try:
1536
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)
1537
1906
  except RuntimeError as e:
1538
1907
  # 领域错误(音视频时长不符 / 分块缺口 / 编码失败 / TTS 重试耗尽 …)给一句话,
1539
1908
  # 不要甩 Python traceback;要完整栈就 set RENDER_DEBUG=1。