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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +951 -0
  2. package/README.md +89 -21
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/index.js +23 -7
  5. package/package.json +7 -3
  6. package/schemas/film.schema.json +235 -4
  7. package/skills/video-studio/SKILL.md +299 -27
  8. package/skills/video-studio/assets/film.json +7 -14
  9. package/skills/video-studio/assets/golden_frames.json +65 -0
  10. package/skills/video-studio/assets/scenes.py +115 -10
  11. package/skills/video-studio/assets/web/engine.js +81 -3
  12. package/skills/video-studio/assets/web/film.js +249 -101
  13. package/skills/video-studio/assets/web/index.html +9 -2
  14. package/skills/video-studio/assets/web/qa_text.js +36 -8
  15. package/skills/video-studio/references/audio-variation.md +159 -0
  16. package/skills/video-studio/references/audio.md +67 -5
  17. package/skills/video-studio/references/change-impact.md +2 -2
  18. package/skills/video-studio/references/quality-gates.md +25 -3
  19. package/skills/video-studio/references/style-packs.md +7 -3
  20. package/skills/video-studio/references/v08-quality.md +256 -0
  21. package/skills/video-studio/references/web-engine.md +19 -2
  22. package/skills/video-studio/references/word-timeline.md +147 -0
  23. package/skills/video-studio/requirements.txt +16 -1
  24. package/skills/video-studio/scripts/filmcheck.py +202 -12
  25. package/skills/video-studio/scripts/filmkit.py +792 -56
  26. package/skills/video-studio/scripts/gates.py +161 -14
  27. package/skills/video-studio/scripts/preflight.py +774 -0
  28. package/skills/video-studio/scripts/qa_frames.py +154 -23
  29. package/skills/video-studio/scripts/qaasr.py +1563 -0
  30. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  31. package/skills/video-studio/scripts/render.py +1134 -78
  32. package/skills/video-studio/scripts/scorekit.py +305 -76
  33. package/skills/video-studio/scripts/selfcheck.py +576 -2
  34. package/skills/video-studio/scripts/sfxkit.py +262 -44
  35. package/skills/video-studio/scripts/stagewatch.py +159 -36
  36. package/skills/video-studio/scripts/stylekit.py +137 -18
  37. package/skills/video-studio/scripts/voicekit.py +23 -7
  38. package/skills/video-studio/scripts/webengine.py +1186 -85
  39. package/tools/audio_similarity.py +279 -0
  40. package/tools/ci_check.py +310 -59
  41. package/tools/estimate.py +251 -61
  42. package/tools/golden_frames.py +664 -0
  43. package/tools/selftest_noscipy.py +70 -0
@@ -535,12 +535,77 @@ def check_bbox(box, safe=SAFE, tag=""):
535
535
  return []
536
536
 
537
537
 
538
+ def _split_with_seps(text, words):
539
+ """把原文按词表切开,保留**词与词之间的原始分隔符**(空格/标点)。
540
+
541
+ 返回 [(sep, token), ...] + 尾部残余;任何一步在原文里找不到时就返回 None。
542
+ 为什么要保留 sep:edge-tts 的中文词表不含空格,但剧本里可能有
543
+ "用 DeepSeek 生成"这种中英混排——直接拼 token 会把空格吃成 "用DeepSeek生成"。
544
+ """
545
+ s = str(text or "")
546
+ toks = [str(w.get("text") or "") for w in (words or [])]
547
+ toks = [t for t in toks if t]
548
+ if not toks:
549
+ return None
550
+ out, pos = [], 0
551
+ for tk in toks:
552
+ i = s.find(tk, pos)
553
+ if i < 0:
554
+ return None
555
+ out.append((s[pos:i], tk))
556
+ pos = i + len(tk)
557
+ return out, s[pos:]
558
+
559
+
560
+ def wrap_by_words(text, f, max_w, words):
561
+ """按**真实词边界**折行(v0.8)。词表对不上原文时返回 None(调用方回退字符折行)。
562
+
563
+ 为什么值得:中文按字符折行会在一句话的中间硬切("…不是读/稿"),
564
+ 而词边界是 TTS 自己切的分词结果,折出来的断点读起来自然,也更容易和
565
+ 词级高亮/字幕窗口对齐。
566
+ """
567
+ got = _split_with_seps(text, words)
568
+ if not got:
569
+ return None
570
+ pairs, tail = got
571
+ if tail.strip(): # 词表没覆盖整句:不对齐,交回字符折行
572
+ return None
573
+ lines, cur = [], ""
574
+ for sep, tok in pairs:
575
+ trial = cur + sep + tok
576
+ if cur and text_w(trial, f) > max_w:
577
+ lines.append(cur)
578
+ cur = tok.lstrip()
579
+ else:
580
+ cur = trial
581
+ if tail:
582
+ cur += tail
583
+ if cur:
584
+ lines.append(cur)
585
+ return lines or None
586
+
587
+
538
588
  def fit_sub_lines(text: str, max_lines=2, max_w=1560, size=46, weight="reg",
539
- family="sans"):
540
- """字幕折行:优先两行,超宽逐级缩字号。返回 (lines, font)。"""
589
+ family="sans", words=None):
590
+ """字幕折行:优先两行,超宽逐级缩字号。返回 (lines, font)。
591
+
592
+ v0.8:传 words(句内词表)时优先按词边界折行;词表对不上原文、或折出来
593
+ 仍超过 max_lines 时,**回退到原来的字符折行**——旧项目的排版不会因为升级而变样。
594
+ """
541
595
  for sz in (size, int(size * 0.92), int(size * 0.84), int(size * 0.76)):
542
596
  f = font(sz, weight, family)
543
- lines = wrap_cn(text, f, max_w)
597
+ lines = None
598
+ if words:
599
+ lines = wrap_by_words(text, f, max_w, words)
600
+ if lines is not None and (len(lines) > max_lines
601
+ or any(text_w(l, f) > max_w for l in lines)):
602
+ # 两种情况都退回字符折行:①词边界折出来超过 max_lines;
603
+ # ②某个"词"本身就比 max_w 宽(wrap_by_words 对单个超宽 token 不会
604
+ # 断开,会把它当一整行放出去)。②不兜底时字幕横向溢出安全区,而
605
+ # qa_text_safe 用字符折行量宽度、看不出问题——门禁和画面不是同一套折法。
606
+ lines = None
607
+ if lines is None:
608
+ lines = wrap_cn(text, f, max_w)
544
609
  if len(lines) <= max_lines:
545
610
  return lines, f
546
611
  f = font(int(size * 0.76), weight, family)
@@ -548,16 +613,16 @@ def fit_sub_lines(text: str, max_lines=2, max_w=1560, size=46, weight="reg",
548
613
 
549
614
 
550
615
  def draw_subtitle(img, text: str, alpha=1.0, size=46, y=None, band_w=None,
551
- theme=None, family=None):
616
+ theme=None, family=None, words=None):
552
617
  """烧字幕。v0.5.0 加 theme(stylekit 主题)与 family(字体族);
553
- theme=None 时与 v0.4 逐像素一致。"""
618
+ theme=None 时与 v0.4 逐像素一致。v0.8 加 words(按词边界折行)。"""
554
619
  if not text or alpha <= 0.01:
555
620
  return img
556
621
  if band_w is None:
557
622
  band_w = SUB_BAND[2] - SUB_BAND[0]
558
623
  th = theme if isinstance(theme, dict) else None
559
624
  fam = family or (th or {}).get("family") or "sans"
560
- lines, f = fit_sub_lines(text, max_w=band_w, size=size, family=fam)
625
+ lines, f = fit_sub_lines(text, max_w=band_w, size=size, family=fam, words=words)
561
626
  lines = lines[:2] # 硬约束:最多两行
562
627
  lh = int(f.size * 1.32)
563
628
  cx = (SUB_BAND[0] + SUB_BAND[2]) // 2
@@ -591,6 +656,12 @@ def wav_write(path, samples, sr=SR, channels=None):
591
656
  v0.3.0:支持立体声(配乐/音效要声像)。老调用 wav_write(p, mono) 行为不变。
592
657
  """
593
658
  a = np.asarray(samples, dtype=np.float32)
659
+ # 非有限值必须报错:旧实现 np.clip 保留 NaN/Inf,astype(int16) 把 NaN 静默写成 0、
660
+ # 把 Inf 写成 undefined,wav 里出现"随机哑音"且函数还返回 nan 峰值(下游只看得到 no)。
661
+ if a.size and not np.all(np.isfinite(a)):
662
+ bad = int(a.size - np.count_nonzero(np.isfinite(a)))
663
+ raise ValueError("wav_write: 采样里有 %d 个 NaN/Inf(%s)——上游混音/合成有数值错误,"
664
+ "拒绝写出损坏的 WAV" % (bad, path))
594
665
  if a.ndim == 2:
595
666
  n_ch = a.shape[1]
596
667
  if channels is None:
@@ -722,6 +793,12 @@ class AudioBus:
722
793
  if i0 >= len(self.bus):
723
794
  return self
724
795
  s = np.asarray(src, dtype=np.float32)
796
+ if i0 < 0:
797
+ # 负起点 = 从 0 秒开始、裁掉前面 |i0| 个采样(与 sfxkit._place 同口径)。
798
+ # 旧实现把负 i0 直接当负下标切片,n 变成 0 后广播崩(place(src, -0.25) 必炸)。
799
+ if i0 <= -len(s):
800
+ return self
801
+ s, i0 = s[-i0:], 0
725
802
  if pan_pos is not None and self.channels == 2 and s.ndim == 1:
726
803
  theta = (float(np.clip(pan_pos, -1.0, 1.0)) + 1.0) * np.pi / 4.0
727
804
  s = np.stack([s * np.cos(theta), s * np.sin(theta)], axis=1)
@@ -749,6 +826,11 @@ class AudioBus:
749
826
  return self
750
827
  t = float(start)
751
828
  idx = 0
829
+ if not (float(seg) > 0.0):
830
+ # seg=0 时 t 不前进 -> while 变成死循环(100000 次上限只是"慢死",
831
+ # 6 秒片实测 >2 分钟)。schema 只约束 seg>=0,必须在入口拒绝。
832
+ raise ValueError("music_bed: seg 必须 > 0,收到 %r(v0.2 和弦床的段长,默认 4.0)"
833
+ % (seg,))
752
834
  while t < self.total:
753
835
  freqs = chords[idx % len(chords)]
754
836
  dur = min(seg + xfade, max(0.25, self.total - t + xfade))
@@ -804,10 +886,13 @@ CUE_BASE_DB = {
804
886
  def cue_time(at, scenes=None, total=None):
805
887
  """把提示点的 at 解析成成片绝对秒。
806
888
 
807
- 支持三种写法(CONTRACT.md §4.1):
889
+ 支持四种写法(CONTRACT.md §4.1):
808
890
  "abs:12.5" / 12.5 -> 绝对秒
809
891
  "l03+0.35" / "l03-0.2" -> 场景 id 加/减偏移(相对场景 start)
810
892
  "l03" -> 场景起点
893
+ "l03:w2" / "l03:w2+0.1" -> **词锚点**(v0.8):该句第 2 个词的起点 + 偏移。
894
+ 下标从 1 开始(w1 = 第一个词);找不到该词时**退化为句偏移**并告警,
895
+ 不让一个写错的锚点把整条 cues 链路炸掉(与 v0.7.2 的"跳过并留痕"一致)。
811
896
  """
812
897
  if isinstance(at, (int, float)):
813
898
  return round(float(at), 3)
@@ -820,6 +905,32 @@ def cue_time(at, scenes=None, total=None):
820
905
  return round(float(s), 3)
821
906
  except ValueError:
822
907
  pass
908
+ # 词锚点:<id>:w<N>[+/-<offset>]
909
+ if ":w" in s:
910
+ head, _sep, tail = s.partition(":w")
911
+ # tail 形如 "2" / "2+0.1" / "2-0.1":词序号 + 可选偏移(偏移可带符号)
912
+ m = re.match(r"^([0-9]+)(.*)$", tail)
913
+ if m:
914
+ num = m.group(1)
915
+ rest = m.group(2) or ""
916
+ off = 0.0
917
+ if rest:
918
+ try:
919
+ off = float(rest)
920
+ except ValueError:
921
+ off = 0.0
922
+ if scenes:
923
+ for sc in scenes:
924
+ if str(sc.get("id")) != head:
925
+ continue
926
+ ws = sc.get("words_abs") or sc.get("words") or []
927
+ idx = int(num) - 1
928
+ if 0 <= idx < len(ws):
929
+ return round(float(ws[idx]["start"]) + off, 3)
930
+ print(" [WARN] cue at=%r:%s 只有 %d 个词,w%s 不存在,退化为句偏移"
931
+ % (at, head, len(ws), num), flush=True)
932
+ return round(float(sc["start"]) + off, 3)
933
+ raise ValueError("cue at=%r:找不到场景 %r(先跑 layout,或改用 abs:秒)" % (at, head))
823
934
  # 形如 <id>[+-]<offset>
824
935
  cut = len(s)
825
936
  for i, ch in enumerate(s):
@@ -841,6 +952,21 @@ def scenes_anchors(scenes):
841
952
  start=float(s["start"]), end=float(s["end"])) for s in scenes]
842
953
 
843
954
 
955
+ def content_seed(base: int, style: str, content_fp: str, recipe: str, t: float) -> int:
956
+ """内容派生 seed(v0.7):同影片同 cue 稳定,不同影片同一位置也换音色细节。
957
+
958
+ 公式与 sfxkit.content_seed 一致(references/audio-variation.md §3.1):
959
+ sha1("v0.7|<base>|<style>|<content_fp>|<recipe>|<t 三位小数>") -> 前 8 位 hex -> % 9000 + 1000
960
+
961
+ 为什么不用 seed_base + i:那样只跟 cue 的**出现顺序**有关,两部内容完全不同的片子
962
+ 只要 cues 一样,音效轨就逐样本相同(跨片"音效都一样"的直接来源)。
963
+ """
964
+ h = hashlib.sha1(
965
+ ("v0.7|%d|%s|%s|%s|%.3f" % (int(base), str(style), str(content_fp), str(recipe), float(t)))
966
+ .encode("utf-8")).hexdigest()
967
+ return int(h[:8], 16) % 9000 + 1000
968
+
969
+
844
970
  class CueBus:
845
971
  """提示点登记表:画面侧登记 -> 落盘 cues.json -> 音频侧按它合成音效。
846
972
 
@@ -852,6 +978,7 @@ class CueBus:
852
978
  self.scenes = scenes or []
853
979
  self.total = None if total is None else float(total)
854
980
  self.items = []
981
+ self._drop_warned = set() # 已告警过的丢弃项:同一实例只报一次(见 resolved)
855
982
 
856
983
  # -- 登记 --
857
984
  def add(self, at, type, **params):
@@ -877,15 +1004,36 @@ class CueBus:
877
1004
 
878
1005
  # -- 解析 --
879
1006
  def resolved(self):
880
- """按时间排序、去掉明显无效项(超过片长 2 秒的丢弃并告警)。"""
1007
+ """按时间排序、去掉明显无效项(超过片长 2 秒 / 负时刻),并**真的**告警。
1008
+
1009
+ 为什么必须告警:st_sfx 只把 build_audio 的产物当音效轨,被丢掉的 cue 不会进
1010
+ 任何报告;以前这里静默 continue(文档却写着"丢弃并告警"),少一个提示音时
1011
+ 用户只能靠听。st_sfx 会调两次 resolved()(自己一次 + build_audio 内部一次),
1012
+ 所以只在**第一次**发现丢弃时打一条汇总,避免同一件事刷两遍。
1013
+ """
881
1014
  out = []
1015
+ dropped = []
882
1016
  for row in self.items:
883
1017
  t = float(row["t"])
884
1018
  if t < -0.05:
1019
+ dropped.append(("负时刻", t))
885
1020
  continue
886
1021
  if self.total is not None and t > self.total + 2.0:
1022
+ dropped.append(("超出片长", t))
887
1023
  continue
888
1024
  out.append(row)
1025
+ fresh = [d for d in dropped if d not in self._drop_warned]
1026
+ if fresh:
1027
+ self._drop_warned.update(fresh)
1028
+ late = [x for x in fresh if x[0] == "超出片长"]
1029
+ neg = [x for x in fresh if x[0] == "负时刻"]
1030
+ parts = []
1031
+ if late:
1032
+ parts.append("%d 条超过片长(%.2fs)+2s" % (len(late), float(self.total)))
1033
+ if neg:
1034
+ parts.append("%d 条时刻为负" % len(neg))
1035
+ print(" [WARN] CueBus: 丢弃 %d 条提示点(%s)——检查 cue 的 at 写法/场景 id"
1036
+ % (len(fresh), ";".join(parts)), flush=True)
889
1037
  return sorted(out, key=lambda r: float(r["t"]))
890
1038
 
891
1039
  def to_json(self, duration=None, fps=None, width=None, height=None, events=None):
@@ -918,11 +1066,21 @@ class CueBus:
918
1066
 
919
1067
  # -- 合成 --
920
1068
  def build_audio(self, total=None, sr=SR, base_db=None, default_gain_db=0.0,
921
- seed_base=1000, verbose=True):
1069
+ seed_base=1000, verbose=True,
1070
+ style=None, content_fp=None, key=None):
922
1071
  """按提示点合成音效轨 -> (n,2) float32。
923
1072
 
924
1073
  依赖 scripts/sfxkit.py(v0.3.0 新增)。缺 sfxkit 时直接报错,不静默出哑片。
925
1074
  未知提示点类型:告警一次并跳过(不 crash 整片混音)。
1075
+ 提示点参数非法(配方不认识的关键字 / 取值连枚举校验都过不了,TypeError 或
1076
+ ValueError):该条跳过并汇总 [WARN],其余正常落位——不静默改渲染默认音色,
1077
+ 也不因一条 cue 写错丢整条音效轨。
1078
+
1079
+ v0.7 音频差异化(见 references/audio-variation.md):
1080
+ - style / content_fp 同时给时,每条 cue 的 seed 由
1081
+ content_seed(seed_base, style, content_fp, type, t) 派生 -> 不同影片同一 cue
1082
+ 位置也拿到不同音色细节;任一为 None 时退回 seed_base + i(旧路径逐样本兼容)。
1083
+ - key 非空时透传给配方(提示音调性跟随配乐,不再硬编码 Dm)。
926
1084
  """
927
1085
  try:
928
1086
  import sfxkit as SX
@@ -937,19 +1095,46 @@ class CueBus:
937
1095
  n = int(round(dur * sr)) + int(sr * 0.5)
938
1096
  bus = np.zeros((n, 2), dtype=np.float32)
939
1097
  unknown, placed = set(), 0
1098
+ bad_cues: list = [] # 参数非法/内部报错的 cue:跳过并汇总 WARN,不拖死整条音效轨
1099
+ keyed = style is not None and content_fp is not None
1100
+ if keyed and verbose:
1101
+ print(" 音效 seed: 内容派生(style=%s / fp=%s)" % (style, str(content_fp)[:8]))
1102
+ elif verbose and style is not None:
1103
+ print(" 音效 seed: 位置派生(只给了 style 没给 content_fp)")
940
1104
  for i, c in enumerate(self.resolved()):
941
1105
  kind = str(c.get("type"))
942
1106
  if kind not in reg:
943
1107
  unknown.add(kind)
944
1108
  continue
945
1109
  params = {k: v for k, v in c.items()
946
- if k not in ("t", "type", "src", "gain", "pan", "base_db")}
1110
+ if k not in ("t", "type", "src", "gain", "pan", "base_db", "key")}
947
1111
  params.pop("dur_hint", None)
1112
+ if keyed:
1113
+ seed = content_seed(seed_base, style, content_fp, kind, float(c["t"]))
1114
+ else:
1115
+ seed = int(seed_base) + i
1116
+ if key is not None:
1117
+ params["key"] = key
948
1118
  try:
949
- x = SX.render(kind, seed=int(seed_base) + i, **params)
950
- except TypeError:
951
- # 兼容严格签名的配方:退化成只传 seed
952
- x = SX.render(kind, seed=int(seed_base) + i)
1119
+ x = SX.render(kind, seed=seed, **params)
1120
+ except (TypeError, ValueError) as err:
1121
+ # 只对"配方不认识这个关键字"逐个删参重试(v0.4 严格签名配方的兼容);
1122
+ # 参数值非法(cue 里 pitch=None 之类)或配方内部 TypeError 不是签名问题,
1123
+ # 必须报告——旧实现一路退化成"只传 seed",把坏参数静默渲染成默认音色
1124
+ # (用户改了 cue 参数却听不出变化,日志里一个字都没有)。
1125
+ extra = dict(params)
1126
+ while True:
1127
+ hit = re.search(r"unexpected keyword argument '([^']+)'", str(err))
1128
+ if not hit or hit.group(1) not in extra:
1129
+ bad_cues.append((kind, float(c["t"]), err))
1130
+ x = None
1131
+ break
1132
+ extra.pop(hit.group(1))
1133
+ try:
1134
+ x = SX.render(kind, seed=seed, **extra)
1135
+ break
1136
+ except (TypeError, ValueError) as err2:
1137
+ err = err2
953
1138
  if x is None or len(x) == 0:
954
1139
  continue
955
1140
  x = np.asarray(x, dtype=np.float32)
@@ -968,12 +1153,123 @@ class CueBus:
968
1153
  placed += 1
969
1154
  if unknown and verbose:
970
1155
  print(" [WARN] 未知提示点类型(已跳过): %s" % ", ".join(sorted(unknown)))
1156
+ if bad_cues and verbose:
1157
+ # 与"未知 type 跳过"同一条隔离策略:一条 cue 的参数写错不该让整条音效轨消失
1158
+ # (st_sfx 会把 build_audio 的异常当成"音效合成失败"整轨丢弃)。但也不能静默:
1159
+ # 逐条打印 cue 类型/时刻/原因,用户改完参数能立刻看到。
1160
+ print(" [WARN] %d 条提示点参数非法(已跳过,其余正常落位):" % len(bad_cues))
1161
+ for kind_b, t_b, err_b in bad_cues[:6]:
1162
+ print(" %-14s t=%.3fs -> %s" % (kind_b, t_b, str(err_b)[:110]))
971
1163
  if verbose:
972
1164
  print(" 音效轨:%d 个提示点落位 / %d 个登记(%.2fs)"
973
1165
  % (placed, len(self.items), dur))
974
1166
  return bus[: int(round(dur * sr))]
975
1167
 
976
1168
 
1169
+ def voice_env(voice, sr=SR, block_s=0.01, thresh_db=-42.0, floor_db=-60.0):
1170
+ """旁白的语音活跃度包络(0~1):给"旁白优先"混音用的侧链源。
1171
+
1172
+ 为什么需要它:v0.7 之前配乐基线只比旁白低 1.5 dB(`music.gain_db=-1.5`),
1173
+ 而视频是"以旁白为主"的——背景只要不明显低于人声,观众就得费力分辨字。
1174
+ `mix_film` 现在用这条包络在**有人声的地方**把配乐/音效再压下去,
1175
+ 没人声的间隙自动让背景回来(否则整片背景像被闷住)。
1176
+
1177
+ 实现:10 ms 块级 RMS -> dB -> 在 [floor_db, thresh_db] 之间线性映射成 0~1 ->
1178
+ 起音快 / 释放慢的单向平滑(与 `duck()` 同一套向量化手法,无逐采样循环)。
1179
+ """
1180
+ x = np.asarray(voice, dtype=np.float32)
1181
+ if x.ndim == 2:
1182
+ x = x.mean(axis=1)
1183
+ n = len(x)
1184
+ if n == 0:
1185
+ return np.zeros(0, dtype=np.float32)
1186
+ block = max(1, int(sr * float(block_s)))
1187
+ nb = n // block
1188
+ if nb == 0: # 极短旁白(< 10 ms):整段当一块并补零
1189
+ nb = 1 # (旧实现 reshape(1, block) 直接崩)
1190
+ x = np.concatenate([x, np.zeros(block - n, dtype=np.float32)])
1191
+ seg = x[: nb * block].reshape(nb, block)
1192
+ rms = np.sqrt(np.mean(seg * seg, axis=1) + 1e-12)
1193
+ db = 20.0 * np.log10(rms + 1e-12)
1194
+ lo = float(min(floor_db, thresh_db - 1.0))
1195
+ e = np.clip((db - lo) / (float(thresh_db) - lo), 0.0, 1.0)
1196
+
1197
+ def _smooth(v, tau_s):
1198
+ # 极短旁白时块数可能比核还少:np.convolve(mode="same") 返回 max(len(v), k) 长度,
1199
+ # 两个长度不同的平滑结果再做 np.maximum 会广播失败(v0.7.1 起 voice_env 崩在这里)。
1200
+ k = min(max(1, int(sr * tau_s / block)), len(v))
1201
+ w = np.ones(k, dtype=np.float32) / k
1202
+ return np.convolve(v, w, mode="same")
1203
+
1204
+ e = np.maximum(_smooth(e, 0.06), _smooth(e, 0.45)).astype(np.float32)
1205
+ return np.interp(np.arange(n), np.arange(nb) * block + block / 2.0, e).astype(np.float32)
1206
+
1207
+
1208
+ def duck_voice(track, voice, depth_db=6.0, sr=SR, thresh_db=-42.0, floor_db=-60.0):
1209
+ """旁白说话时压低背景(配乐 / 音效):旁白优先的侧链压缩(只压背景,不碰人声)。
1210
+
1211
+ depth_db 是"人声满电平时背景最多被压多少 dB";间隙里包络回 0,背景恢复原电平。
1212
+ 与 `duck()`(音效压配乐)叠加使用:人声 > 音效 > 配乐,三条让位链互不冲突。
1213
+ """
1214
+ b = np.asarray(track, dtype=np.float32)
1215
+ if b.ndim == 1:
1216
+ b2 = np.stack([b, b], axis=1)
1217
+ else:
1218
+ b2 = b
1219
+ n = len(b2)
1220
+ if n == 0 or depth_db <= 0:
1221
+ return b2
1222
+ e = voice_env(voice, sr=sr, thresh_db=thresh_db, floor_db=floor_db)[:n]
1223
+ if e.size == 0:
1224
+ return b2
1225
+ if e.size < n:
1226
+ # 旁白比背景短(单独调用时可能遇到):旁白结束之后没有侧链源,背景应原样恢复。
1227
+ e = np.concatenate([e, np.zeros(n - e.size, dtype=np.float32)])
1228
+ if float(np.max(e)) <= 1e-6:
1229
+ return b2
1230
+ gains = 10.0 ** (-float(depth_db) * e / 20.0)
1231
+ return (b2 * gains[:, None]).astype(np.float32)
1232
+
1233
+
1234
+ def voice_snr(voice, bg, sr=SR, thresh_db=-42.0, floor_db=-60.0):
1235
+ """在"有人声"的采样区间量 人声/背景 的响度差(dB)。旁白优先的客观指标。
1236
+
1237
+ 只在语音活跃度 >= 0.5 的区间统计:静音间隙里背景当然是唯一声源,
1238
+ 把间隙算进来会稀释问题(真正让人听不清的,是人说话那一刻背景有多响)。
1239
+ 人声总长不足 50 ms 时返回 None(没有可判定的样本)。
1240
+ """
1241
+ v = np.asarray(voice, dtype=np.float64)
1242
+ b = np.asarray(bg, dtype=np.float64)
1243
+ if v.ndim == 2:
1244
+ v = v.mean(axis=1)
1245
+ if b.ndim == 2:
1246
+ b = b.mean(axis=1)
1247
+ n = min(len(v), len(b))
1248
+ if n <= 0:
1249
+ return None
1250
+ v, b = v[:n], b[:n]
1251
+ e = voice_env(v, sr=sr, thresh_db=thresh_db, floor_db=floor_db)
1252
+ act = e >= 0.5
1253
+ if int(np.sum(act)) < int(0.05 * sr):
1254
+ return None
1255
+ vr = float(np.sqrt(np.mean(v[act] ** 2)))
1256
+ br = float(np.sqrt(np.mean(b[act] ** 2)))
1257
+ return 20.0 * np.log10((vr + 1e-12) / (br + 1e-12))
1258
+
1259
+
1260
+ def guard_snr(voice, bg, target_db=12.0, cap_db=12.0, sr=SR,
1261
+ thresh_db=-42.0, floor_db=-60.0):
1262
+ """旁白清晰度兜底:人声/背景不足 target_db 时,返回背景应再压多少 dB。
1263
+
1264
+ 返回 (trim_db, snr_db):trim_db=0 表示已达标;cap_db 防止把背景压到无声
1265
+ (压过头会变成"配乐忽有忽无",比听不清更假)。判定口径同 `voice_snr()`。
1266
+ """
1267
+ snr = voice_snr(voice, bg, sr=sr, thresh_db=thresh_db, floor_db=floor_db)
1268
+ if snr is None or snr >= float(target_db):
1269
+ return 0.0, (snr if snr is not None else float("nan"))
1270
+ return float(min(max(0.0, float(cap_db)), float(target_db) - snr)), snr
1271
+
1272
+
977
1273
  def duck(music, sfx, depth_db=4.0, attack=0.03, release=0.35, sr=SR):
978
1274
  """音效响起时配乐自动让位(最多 depth_db),避免重击被配乐吃掉。
979
1275
 
@@ -991,13 +1287,18 @@ def duck(music, sfx, depth_db=4.0, attack=0.03, release=0.35, sr=SR):
991
1287
  if env.size == 0 or float(env.max()) <= 1e-9:
992
1288
  return m2
993
1289
  block = max(1, int(sr * 0.01))
994
- nb = max(1, n // block)
1290
+ nb = n // block
1291
+ if nb == 0: # 极短音效(< 10 ms):整段当一块并补零
1292
+ nb = 1
1293
+ env = np.concatenate([env, np.zeros(block - n, dtype=np.float32)])
995
1294
  e = env[: nb * block].reshape(nb, block).max(axis=1)
996
1295
  e = e / (float(np.percentile(e, 99.5)) + 1e-9)
997
1296
  e = np.clip(e, 0.0, 1.0)
998
1297
  # 单向平滑:起音快、释放慢(对包络做两次不同时间常数的指数平滑,仍保持向量化)
999
1298
  def _smooth(x, tau_s):
1000
- k = max(1, int(sr * tau_s / block))
1299
+ # 与 voice_env 同一处理:短片段(< release 0.35 s)时核不能比信号长,
1300
+ # 否则 np.convolve(mode="same") 返回核长、np.maximum 广播失败(短片混音直接崩)。
1301
+ k = min(max(1, int(sr * tau_s / block)), len(x))
1001
1302
  w = np.ones(k, dtype=np.float32) / k
1002
1303
  return np.convolve(x, w, mode="same")
1003
1304
  e = np.maximum(_smooth(e, attack), _smooth(e, release) * 0.7)
@@ -1008,17 +1309,35 @@ def duck(music, sfx, depth_db=4.0, attack=0.03, release=0.35, sr=SR):
1008
1309
 
1009
1310
  def mix_film(voice, music=None, sfx=None, total=None, sr=SR,
1010
1311
  voice_gain=1.0, music_gain_db=0.0, sfx_gain_db=0.0,
1011
- duck_db=3.0, peak_target=0.89, fade=0.4, verbose=True):
1312
+ duck_db=3.0, peak_target=0.89, fade=0.4, verbose=True,
1313
+ voice_duck_db=6.0, voice_duck_sfx_db=3.0,
1314
+ voice_thresh_db=-42.0, voice_floor_db=-60.0,
1315
+ guard_snr_db=12.0, guard_cap_db=30.0):
1012
1316
  """三总线混音:旁白 + 配乐 + 音效 -> 立体声 float32。
1013
1317
 
1014
- 顺序照 ai-history-film 的成片混音:音乐比音效低一点,音效响时音乐让位,
1015
- 最后统一做峰值保护(真正的响度标准化交给 ffmpeg 的 loudnorm)。
1318
+ **旁白优先**(v0.7.1 起是硬行为,不是文档口号):
1319
+ 1. 旁白电平固定 `voice_gain=1.0`,不被任何侧链影响;
1320
+ 2. 有人声的地方,配乐再压 `voice_duck_db`(默认 6 dB)、音效再压 `voice_duck_sfx_db`(默认 3 dB)
1321
+ —— 保证"人声比背景响 ≥12 dB"这条可听清线;没人声的间隙自动恢复;
1322
+ 3. 音效响时配乐额外让位(`duck_db`,v0.7 默认 4 dB);
1323
+ 4. 最后统一做峰值保护(真正的响度标准化交给 ffmpeg 的 loudnorm)。
1324
+
1325
+ `voice_duck_db=0` / `voice_duck_sfx_db=0` 可单独关掉旁白优先让位(旧片对比用)。
1326
+
1327
+ **SNR 兜底(guard_snr_db=12,0 = 关)**:按台词活跃区实测"人声/背景"响度差,
1328
+ 不足 12 dB 就整体再压背景(最多 guard_cap_db),保证"听清旁白"不依赖配乐素材的天然电平——
1329
+ 这是本仓库唯一一条会在混音后自己改电平的门禁。触发时会打印实际 SNR 与补压量。
1016
1330
  """
1017
1331
  def _to_stereo(x):
1018
1332
  a = np.asarray(x, dtype=np.float32)
1019
1333
  if a.ndim == 1:
1020
1334
  return np.stack([a, a], axis=1)
1021
- return a if a.shape[1] >= 2 else np.concatenate([a, a], axis=1)
1335
+ if a.shape[1] == 1:
1336
+ return np.concatenate([a, a], axis=1)
1337
+ # 多于 2 声道(例如把 5.1 的 music.wav 塞进 work/):只取前两声道。
1338
+ # 旧实现原样返回 (n,3+),back += mm 直接广播崩(mix_film 是公共 API,
1339
+ # 而 wav_read 不限制声道数,用户完全可能踩到)。口径与 AudioBus._fit 一致。
1340
+ return a[:, :2] if a.shape[1] > 2 else a
1022
1341
 
1023
1342
  n = None
1024
1343
  v = None if voice is None else _to_stereo(voice)
@@ -1033,18 +1352,57 @@ def mix_film(voice, music=None, sfx=None, total=None, sr=SR,
1033
1352
  if not n:
1034
1353
  return np.zeros((0, 2), dtype=np.float32)
1035
1354
 
1036
- out = np.zeros((n, 2), dtype=np.float32)
1355
+ # v0.7.1 旁白优先:先算背景(配乐 + 音效各自让位),再用 SNR 兜底确认"人声压得住"。
1356
+ # 侧链与 SNR 都用"实际交付的人声"(乘过 voice_gain):voice_gain=0.5 时旧实现用未缩放
1357
+ # 人声算 SNR,日志写"人声/背景=12 dB 达标"、交付出去只有 6 dB(兜底承诺失效)。
1358
+ venv_kw = dict(sr=sr, thresh_db=float(voice_thresh_db), floor_db=float(voice_floor_db))
1359
+ vg = None
1360
+ if v is not None:
1361
+ vg = v[:n] * float(voice_gain)
1362
+ back = np.zeros((n, 2), dtype=np.float32)
1037
1363
  if s is not None:
1038
- out += s[:n] * (10.0 ** (float(sfx_gain_db) / 20.0))
1364
+ ss = s[:n] * (10.0 ** (float(sfx_gain_db) / 20.0))
1365
+ if vg is not None and float(voice_duck_sfx_db) > 0:
1366
+ ss = duck_voice(ss, vg, depth_db=float(voice_duck_sfx_db), **venv_kw)
1367
+ back += ss
1039
1368
  if m is not None:
1040
1369
  mm = m[:n] * (10.0 ** (float(music_gain_db) / 20.0))
1041
1370
  if s is not None:
1042
1371
  mm = duck(mm, s[:n], depth_db=duck_db, sr=sr)
1043
1372
  else:
1044
1373
  mm = _to_stereo(mm)
1045
- out += mm
1374
+ if vg is not None and float(voice_duck_db) > 0:
1375
+ mm = duck_voice(mm, vg, depth_db=float(voice_duck_db), **venv_kw)
1376
+ back += mm
1377
+ # SNR 兜底:实测人声活跃区的人声/背景比;不够就整体再压背景(不碰人声)。
1378
+ if vg is not None and back.any() and float(guard_snr_db) > 0:
1379
+ trim_db, snr_db = guard_snr(vg, back, target_db=float(guard_snr_db),
1380
+ cap_db=float(guard_cap_db), **venv_kw)
1381
+ if trim_db > 0.0:
1382
+ back *= 10.0 ** (-trim_db / 20.0)
1383
+ if verbose:
1384
+ if snr_db == snr_db: # 非 NaN = 有可判定的人声段
1385
+ note = ",达标"
1386
+ if trim_db > 0:
1387
+ note = ",背景再压 %.1f dB" % trim_db
1388
+ if trim_db >= float(guard_cap_db) - 1e-6:
1389
+ note += "(已到兜底上限:检查配乐/音效素材电平)"
1390
+ elif trim_db > 18.0:
1391
+ note += "(补压很大:建议调低 music.gain_db)"
1392
+ print(" 旁白优先: 人声/背景 = %.1f dB(目标 %.1f)%s"
1393
+ % (snr_db, float(guard_snr_db), note))
1394
+ else:
1395
+ # snr_db 是 NaN = 活跃区为空:旁白电平低于 voice_thresh_db 时,旁白优先
1396
+ # 与 SNR 兜底会**整体静默失效**(背景一点不压)。纯静音片是有意的,
1397
+ # 但"目的是说话却录得很轻"必须在日志里喊出来,否则等于没有门禁。
1398
+ vpk = float(np.max(np.abs(vg))) if vg.size else 0.0
1399
+ if vpk > 1e-4: # 有声音但到不了活跃区
1400
+ print(" [WARN] 旁白电平过低(峰值 %.1f dBFS,低于活跃度门限 %.0f dB)——"
1401
+ "旁白优先/SNR 兜底本次无法判定;检查 voice_gain / TTS 音量"
1402
+ % (20.0 * math.log10(vpk), float(voice_thresh_db)))
1403
+ out = back
1046
1404
  if v is not None:
1047
- out += v[:n] * float(voice_gain)
1405
+ out = out + v[:n] * float(voice_gain)
1048
1406
 
1049
1407
  raw = float(np.max(np.abs(out))) if out.size else 0.0
1050
1408
  if fade > 0:
@@ -1271,9 +1629,127 @@ def tts_key_v2(text, voice, rate, pitch, volume="+0%", trim=True, trim_db=-42.0,
1271
1629
  return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16]
1272
1630
 
1273
1631
 
1632
+ # ---------------------------------------------------------------- TTS 后端
1633
+ # 为什么要有这一层(v0.8):整条旁白链路现在只挂在一个**非官方**客户端(edge-tts)上。
1634
+ # 它的服务端随时可能改协议/加鉴权,而我们的时间轴、词级字幕、门禁全部建立在这些产物上。
1635
+ # 这里只做**接口**(不实现第二个后端):把"合成一句话 -> 音频 + 词表"这个动作抽象出来,
1636
+ # 将来要换 Azure 官方 TTS / 离线引擎,只加一个 register 的实现,timeline/layout/QA 一行不改。
1637
+ #
1638
+ # 协议(实现者必须满足):
1639
+ # fn(text, out_mp3, *, voice, rate, pitch, volume, trim, trim_db, words) -> dict
1640
+ # 返回的 dict 至少含:path(可播放音频路径)、dur(实测秒数,**必须是时间轴权威**)、
1641
+ # words(可空列表)、trimmed(bool)
1642
+ TTS_BACKENDS = {}
1643
+
1644
+
1645
+ def register_tts_backend(name, fn, *, note=""):
1646
+ """注册一个 TTS 后端实现。name 用于 film.json 的 tts.backend 选择。"""
1647
+ if not callable(fn):
1648
+ raise TypeError("register_tts_backend 需要一个可调用对象,收到 %r" % (fn,))
1649
+ TTS_BACKENDS[str(name)] = dict(fn=fn, note=str(note or ""))
1650
+ return str(name)
1651
+
1652
+
1653
+ def tts_backend_names():
1654
+ return ["edge"] + sorted(n for n in TTS_BACKENDS if n != "edge")
1655
+
1656
+
1657
+ def tts_words_key(text, voice, rate, pitch, volume="+0%", trim=True,
1658
+ trim_db=-42.0) -> str:
1659
+ """词级元数据的独立缓存键(v0.8)。
1660
+
1661
+ 为什么不并进 tts_key_v2:并进去会让**所有老项目的音频缓存一次性失效**(必须重新联网
1662
+ 合成整片)。分开记 -> 老音频照旧复用,只有"这次显式要词级"时才补一次事件采集。
1663
+ """
1664
+ payload = "%s|%s|%s|%s|%s|%s|%s" % ("words", text, voice, rate, pitch,
1665
+ volume, int(bool(trim)))
1666
+ return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16]
1667
+
1668
+
1669
+ def trim_silence_bounds(pcm, sr=SR, thresh_db=-42.0, keep_ms=80):
1670
+ """返回 trim_silence 会保留的样本区间 [a, b)。
1671
+
1672
+ v0.8 的词级时间戳需要它:edge-tts 的 WordBoundary offset 是相对**原始 mp3** 的,
1673
+ 而 trim=True 时 timeline 用的是裁剪后的语音。不减去 a,词就会整体提前 ~0.1–1 s。
1674
+ 与 trim_silence 共用同一套判据(idx[0]/idx[-1] ± keep),保证两边永远一致。
1675
+ """
1676
+ pcm = np.asarray(pcm, dtype=np.float32)
1677
+ if pcm.size == 0:
1678
+ return 0, 0
1679
+ th = 10 ** (thresh_db / 20.0)
1680
+ idx = np.where(np.abs(pcm) > th)[0]
1681
+ if idx.size == 0:
1682
+ return 0, 0
1683
+ keep = int(sr * keep_ms / 1000.0)
1684
+ a = max(0, int(idx[0]) - keep)
1685
+ b = min(len(pcm), int(idx[-1]) + keep)
1686
+ return a, b
1687
+
1688
+
1689
+ def words_normalize(events, *, offset_shift=0.0, dur=None, min_gap=0.0):
1690
+ """把 edge-tts 的 WordBoundary 事件规整成 "句内词表"。
1691
+
1692
+ 输入事件:{"type":"WordBoundary","offset":<100ns>,"duration":<100ns>,"text":str}
1693
+ 输出行: {"text":str,"start":秒,"dur":秒,"end":秒}(start/end 都是相对本句语音起点,
1694
+ offset_shift 为裁剪掉的句首静音秒数,会从 start 里减掉)。
1695
+
1696
+ 两个必须处理的现实(实测 edge-tts 7.3.1 zh-CN):
1697
+ * 词之间有 0.1–0.3 s 空隙,事件不覆盖空隙 —— 所以 start/dur 会留洞,这是对的;
1698
+ * 句尾静音(约 0.5 s)不在任何事件里 —— 排轴时用"整句实测时长"兜底,不用词时长求和。
1699
+ """
1700
+ out = []
1701
+ for ev in events or []:
1702
+ txt = str(ev.get("text") or "").strip()
1703
+ if not txt:
1704
+ continue
1705
+ try:
1706
+ st = float(ev.get("offset") or 0.0) / 1e7 - float(offset_shift)
1707
+ du = float(ev.get("duration") or 0.0) / 1e7
1708
+ except (TypeError, ValueError):
1709
+ continue
1710
+ if st < 0:
1711
+ # 裁剪把词首切掉一点:钳到 0,不丢词(丢词会让"字幕少一句"这类门禁误报)
1712
+ du = max(0.0, du + st)
1713
+ st = 0.0
1714
+ row = dict(text=txt, start=round(st, 4), dur=round(du, 4),
1715
+ end=round(st + du, 4))
1716
+ out.append(row)
1717
+ out.sort(key=lambda r: r["start"])
1718
+ # 单调性兜底:极端情况下服务端事件的 offset 可能轻微乱序/重叠,
1719
+ # 这里按"后一个不得早于前一个"夹紧,保证下游(字幕、cue 锚点)拿到的是可用的序列。
1720
+ prev_end = 0.0
1721
+ for r in out:
1722
+ if r["start"] < prev_end:
1723
+ r["start"] = round(prev_end, 4)
1724
+ r["end"] = round(r["start"] + r["dur"], 4)
1725
+ prev_end = r["end"]
1726
+ if dur is not None and out:
1727
+ # 末词终点不得超过整句语音(防止边界事件把尾巴算到下一句)
1728
+ lim = float(dur)
1729
+ for r in out:
1730
+ if r["end"] > lim + 1e-6:
1731
+ r["end"] = round(lim, 4)
1732
+ r["dur"] = round(max(0.0, r["end"] - r["start"]), 4)
1733
+ return out
1734
+
1735
+
1736
+ def words_summary(words, dur=None):
1737
+ """词表的自检摘要(给 QC 门禁用:单调性 / 覆盖率 / 末词偏差)。"""
1738
+ ws = list(words or [])
1739
+ n = len(ws)
1740
+ gaps = []
1741
+ for i in range(1, n):
1742
+ gaps.append(round(float(ws[i]["start"]) - float(ws[i - 1]["end"]), 4))
1743
+ covered = round(sum(float(r["dur"]) for r in ws), 4)
1744
+ tail = round(float(dur) - float(ws[-1]["end"]), 4) if (dur and n) else None
1745
+ mono = all(float(ws[i]["start"]) >= float(ws[i - 1]["start"]) for i in range(1, n))
1746
+ return dict(n=n, covered=covered, gaps=gaps, tail=tail, mono=bool(mono),
1747
+ start=round(float(ws[0]["start"]), 4) if n else None)
1748
+
1749
+
1274
1750
  def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1275
1751
  cache=True, retries=4, trim=True, trim_db=-42.0, volume="+0%",
1276
- persona=None, tone=None):
1752
+ persona=None, tone=None, words=False, backend="edge"):
1277
1753
  """逐句合成旁白(带输入校验的缓存 + 网络重试 + 可选尾静音裁剪)。
1278
1754
 
1279
1755
  v0.1 的缓存只判断文件是否存在,改一句台词会静默复用旧音频;
@@ -1286,25 +1762,59 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1286
1762
  d = os.path.dirname(os.path.abspath(out_mp3))
1287
1763
  if d:
1288
1764
  os.makedirs(d, exist_ok=True)
1765
+ # 可替换后端(v0.8):接口只规定"返回音频 + 词表 + 实测时长",
1766
+ # 默认仍是 edge 的内联实现(逐字节不变)。
1767
+ if str(backend or "edge") != "edge":
1768
+ impl = (TTS_BACKENDS.get(str(backend)) or {}).get("fn")
1769
+ if impl is None:
1770
+ raise RuntimeError("未知的 TTS 后端 %r(已注册:%s)"
1771
+ % (backend, ", ".join(tts_backend_names())))
1772
+ row = impl(text, out_mp3, voice=voice, rate=rate, pitch=pitch,
1773
+ volume=str(volume or "+0%"), trim=trim, trim_db=trim_db, words=words)
1774
+ row = dict(row or {})
1775
+ if "dur" not in row or not row.get("path"):
1776
+ raise RuntimeError("TTS 后端 %r 的返回值缺 path/dur(协议见 filmkit.TTS_BACKENDS)"
1777
+ % backend)
1778
+ row.setdefault("text", text)
1779
+ row.setdefault("voice", voice)
1780
+ row.setdefault("backend", str(backend))
1781
+ return row
1289
1782
  meta_path = out_mp3 + ".json"
1290
1783
  vol = str(volume or "+0%")
1291
1784
  key = tts_key_v2(text, voice, rate, pitch, volume=vol, trim=trim, trim_db=trim_db)
1292
1785
  legacy_key = tts_key(text, voice, rate, pitch)
1293
1786
 
1787
+ wk = tts_words_key(text, voice, rate, pitch, volume=vol, trim=trim,
1788
+ trim_db=trim_db)
1789
+
1790
+ def _words_ok(m):
1791
+ """要词级时必须真有词级记录(老缓存没有 -> 补采一次,别静默返回空词表)。"""
1792
+ if not words:
1793
+ return True
1794
+ return bool(m.get("words_key") == wk and m.get("words"))
1795
+
1796
+ audio_cached = None # 可用音频(可能缺词级记录)
1294
1797
  if cache and os.path.exists(out_mp3) and os.path.getsize(out_mp3) > 2000:
1295
1798
  try:
1296
1799
  m = json.load(open(meta_path, encoding="utf-8"))
1297
1800
  # v2 命中:key 已经含 volume/trim,不需要再分叉判断 trimmed
1298
1801
  if m.get("key") == key and m.get("text") == text:
1299
1802
  if m.get("trimmed") and m.get("path") and os.path.exists(m["path"]):
1300
- return m
1803
+ if _words_ok(m):
1804
+ return m
1805
+ audio_cached = m # 有音频、缺词级:断网时降级复用它
1301
1806
  if not m.get("trimmed") and not trim:
1302
- return m
1807
+ if _words_ok(m):
1808
+ return m
1809
+ audio_cached = m
1303
1810
  # v1 老缓存(升级前合成的)只有在参数完全等于 v1 语义时才复用,
1304
1811
  # 避免"改了一个 volume 却拿到老音频"这种升级期的静默错配。
1305
- elif (legacy_key == tts_key(text, voice, rate, pitch)
1306
- and str(m.get("key") or "") == legacy_key and m.get("text") == text
1307
- and vol in ("+0%", "") and m.get("voice") == voice):
1812
+ # trim 也必须一致:v1 的 key 里没有 trim,旧记录若 trimmed=True,用户把
1813
+ # trim_silence 改成 false 后仍会命中并返回**裁剪版**音频(v0.4 修过的同一类
1814
+ # "关了裁剪其实没关",只是漏在 v1 兼容分支上)。
1815
+ elif (str(m.get("key") or "") == legacy_key and m.get("text") == text
1816
+ and vol in ("+0%", "") and m.get("voice") == voice
1817
+ and bool(m.get("trimmed")) == bool(trim)):
1308
1818
  if m.get("trimmed") and m.get("path") and os.path.exists(m["path"]):
1309
1819
  return m
1310
1820
  except Exception:
@@ -1313,13 +1823,41 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1313
1823
  import asyncio
1314
1824
  import time as _time
1315
1825
 
1826
+ raw_events = []
1316
1827
  for attempt in range(max(1, int(retries))):
1317
1828
  try:
1318
1829
  import edge_tts
1319
1830
 
1320
1831
  async def go():
1321
- c = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch, volume=vol)
1322
- await c.save(out_mp3)
1832
+ # v0.8:要词级时不能再用 save()——save 会把 WordBoundary 丢掉(只写音频)。
1833
+ # 显式传 boundary="WordBoundary":默认是 SentenceBoundary,此时一条词事件都收不到
1834
+ # (实测 edge-tts 7.3.1:不传 boundary 时 zh-CN 词事件数 = 0)。
1835
+ c = edge_tts.Communicate(
1836
+ text, voice, rate=rate, pitch=pitch, volume=vol,
1837
+ boundary="WordBoundary" if words else "SentenceBoundary")
1838
+ if not words:
1839
+ await c.save(out_mp3)
1840
+ return
1841
+ raw_events.clear()
1842
+ # 先写 .part 再 os.replace:中途中失败/断网时**绝不能**截断已有的缓存音频
1843
+ # (旧实现直接 "wb" 打开目标文件,一次失败就把好音频清成 0 字节)。
1844
+ part = out_mp3 + ".part"
1845
+ try:
1846
+ with open(part, "wb") as fh:
1847
+ async for chunk in c.stream():
1848
+ if chunk["type"] == "audio":
1849
+ fh.write(chunk["data"])
1850
+ else:
1851
+ raw_events.append(dict(chunk))
1852
+ if not os.path.exists(part) or os.path.getsize(part) <= 2000:
1853
+ raise RuntimeError("合成产物异常")
1854
+ os.replace(part, out_mp3)
1855
+ finally:
1856
+ if os.path.exists(part):
1857
+ try:
1858
+ os.remove(part)
1859
+ except OSError:
1860
+ pass
1323
1861
 
1324
1862
  asyncio.run(go())
1325
1863
  if not os.path.exists(out_mp3) or os.path.getsize(out_mp3) <= 2000:
@@ -1327,22 +1865,48 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1327
1865
  break
1328
1866
  except Exception as e: # noqa: BLE001
1329
1867
  if attempt == max(1, int(retries)) - 1:
1868
+ if audio_cached is not None and words:
1869
+ # 断网 / 服务端拒绝时的降级:**音频还在**,只是这次没词级。
1870
+ # 不静默:打 WARN,并在 meta 里记 words_ok=False,让 gates 判 skipped。
1871
+ print(" [WARN] %s: 拿不到词级时间戳(%s),复用已有音频;"
1872
+ "本次词级门禁会记 skipped" % (os.path.basename(out_mp3), e),
1873
+ flush=True)
1874
+ audio_cached["words"] = []
1875
+ audio_cached["words_ok"] = False
1876
+ audio_cached["words_events"] = 0
1877
+ return audio_cached
1330
1878
  raise RuntimeError("edge-tts 合成失败(重试 %d 次):%s" % (retries, e)) from e
1331
1879
  _time.sleep(1.2 * (attempt + 1))
1332
1880
 
1333
- dur_raw = audio_dur(out_mp3)
1881
+ # trim=True 时同一句 mp3 只需要解码一次:audio_dur() 内部就是 decode_pcm,
1882
+ # 旧实现先 audio_dur 再 decode_pcm,每句多起一个 ffmpeg 进程、多做一次全量解码
1883
+ # (55 句模板片就是 55 次多余的解码 + 进程启动,而 TTS 阶段本身是网络瓶颈)。
1884
+ pcm_raw = decode_pcm(out_mp3) if trim else None
1885
+ dur_raw = len(pcm_raw) / float(SR) if pcm_raw is not None else audio_dur(out_mp3)
1334
1886
  used = out_mp3
1335
1887
  dur = dur_raw
1888
+ shift = 0.0 # 句首被裁掉的静音秒数(词时间戳要减掉它)
1336
1889
  if trim:
1337
- pcm = trim_silence(decode_pcm(out_mp3), thresh_db=trim_db)
1890
+ a, b = trim_silence_bounds(pcm_raw, thresh_db=trim_db)
1891
+ pcm = pcm_raw[a:b] if b > a else pcm_raw
1338
1892
  wav_path = os.path.splitext(out_mp3)[0] + ".trim.wav"
1339
1893
  wav_write(wav_path, pcm)
1340
1894
  used = wav_path
1341
1895
  dur = len(pcm) / float(SR)
1896
+ shift = a / float(SR) if pcm_raw.size else 0.0
1342
1897
 
1343
1898
  m = dict(path=used, mp3=out_mp3, text=text, voice=voice, rate=rate, pitch=pitch,
1344
1899
  volume=vol, key=key, spec_version=2, dur=dur, dur_raw=dur_raw,
1345
1900
  trimmed=bool(trim), trim_db=float(trim_db))
1901
+ if words:
1902
+ wlist = words_normalize(raw_events, offset_shift=shift, dur=dur)
1903
+ m["words"] = wlist
1904
+ m["words_key"] = wk
1905
+ m["words_events"] = len(raw_events)
1906
+ m["words_shift"] = round(float(shift), 4)
1907
+ # 词事件收不到不是崩溃,而是"服务端行为变了":留空表 + 事件计数,
1908
+ # 由 gates 判 skipped 并写进 QC 报告(不许静默当成"有词级")。
1909
+ m["words_ok"] = bool(wlist)
1346
1910
  if persona:
1347
1911
  m["persona"] = str(persona)
1348
1912
  if tone:
@@ -1352,7 +1916,8 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1352
1916
 
1353
1917
 
1354
1918
  def tts_batch(lines, workdir, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1355
- workers=4, trim=True, plan=None, trim_db=-42.0):
1919
+ workers=4, trim=True, plan=None, trim_db=-42.0, words=True,
1920
+ backend="edge"):
1356
1921
  """并发逐句合成(并发只影响墙钟,不影响产物:每句独立缓存)。
1357
1922
 
1358
1923
  v0.5.0:plan=[{voice,rate,pitch,volume,persona,tone}, ...](与 lines 等长)时
@@ -1375,7 +1940,7 @@ def tts_batch(lines, workdir, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0
1375
1940
  rate=spec.get("rate") or rate,
1376
1941
  pitch=spec.get("pitch") or pitch,
1377
1942
  volume=spec.get("volume") or "+0%",
1378
- trim=trim, trim_db=trim_db,
1943
+ trim=trim, trim_db=trim_db, words=words, backend=backend,
1379
1944
  persona=spec.get("persona"), tone=spec.get("tone"))
1380
1945
  row = dict(ln)
1381
1946
  row.update(m)
@@ -1390,15 +1955,75 @@ def tts_batch(lines, workdir, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0
1390
1955
  return results
1391
1956
 
1392
1957
 
1958
+ # ---------------------------------------------------------------- 画幅规划
1959
+ VIDEO_FPS_ALLOWED = (24, 25, 30, 50, 60)
1960
+ VIDEO_OUT_ALLOWED = ((1920, 1080), (1280, 720), (960, 540), (2560, 1440))
1961
+ VIDEO_ASPECT = 16.0 / 9.0
1962
+
1963
+
1964
+ def video_plan(script=None):
1965
+ """解析 film.json 的 video 块 -> 内部分辨率 / 输出分辨率 / 帧率(v0.8)。
1966
+
1967
+ 设计取舍(都是刻意的):
1968
+ * **只做横屏 16:9**。竖屏不是"把宽高换一下":安全区、版式几何、HUD、字幕折行
1969
+ 全部按 16:9 的像素坐标写死,换比例要重做一套版式(用户已确认本轮不做)。
1970
+ 竖屏/非 16:9 直接报错,而不是悄悄出个变形或带黑边的片子。
1971
+ * **构图恒定 1920x1080,只允许下采样输出**(720p/540p/1440p 里 1440p 需上采样,
1972
+ 因此默认拒绝):上采样不会增加信息,只会把"1080p 的清晰度"包装成"4K",
1973
+ 与项目"文档真话化 / 不糊弄"的原则冲突。要 4K 就改内部构图分辨率。
1974
+ * fps 只接受常见交付值;改 fps 只改时间→帧的换算,不改变画面内容。
1975
+ """
1976
+ v = dict((script or {}).get("video") or {})
1977
+ try:
1978
+ fps = int(v.get("fps") or (script or {}).get("fps") or FPS)
1979
+ except (TypeError, ValueError):
1980
+ raise ValueError("video.fps 必须是整数,收到 %r" % (v.get("fps"),))
1981
+ if fps not in VIDEO_FPS_ALLOWED:
1982
+ raise ValueError("video.fps=%d 不支持(可用:%s)"
1983
+ % (fps, "/".join(str(x) for x in VIDEO_FPS_ALLOWED)))
1984
+ try:
1985
+ ow = int(v.get("width") or v.get("out_width") or W)
1986
+ oh = int(v.get("height") or v.get("out_height") or H)
1987
+ except (TypeError, ValueError):
1988
+ raise ValueError("video.width/height 必须是整数,收到 %r/%r"
1989
+ % (v.get("width"), v.get("height")))
1990
+ if (ow, oh) == (W, H):
1991
+ return dict(fps=fps, w=W, h=H, out_w=ow, out_h=oh, scale=False,
1992
+ label="%dx%d" % (ow, oh))
1993
+ if ow < oh:
1994
+ raise ValueError("video.width=%d height=%d 是竖屏:本项目只支持 16:9 横屏"
1995
+ "(安全区/版式按横屏像素写死,竖屏是另一套版式,本轮不做)"
1996
+ % (ow, oh))
1997
+ if abs(ow / float(oh) - VIDEO_ASPECT) > 0.01:
1998
+ raise ValueError("video.width=%d height=%d 不是 16:9(%.3f:1):换宽高比要重做"
1999
+ "版式几何与字幕安全区,本轮只支持 16:9 横屏的缩放输出"
2000
+ % (ow, oh, ow / float(oh)))
2001
+ if (ow, oh) not in VIDEO_OUT_ALLOWED:
2002
+ raise ValueError("video 输出尺寸 %dx%d 不在允许列表 %s 内(要新尺寸先确认镜头/字号"
2003
+ "在目标分辨率下仍然成立,再把它加进 VIDEO_OUT_ALLOWED)"
2004
+ % (ow, oh, ", ".join("%dx%d" % s for s in VIDEO_OUT_ALLOWED)))
2005
+ if ow > W or oh > H:
2006
+ raise ValueError("video 输出 %dx%d 大于内部构图 %dx%d:那是上采样,只会把 1080p 的"
2007
+ "清晰度包装成更高分辨率(假 4K)。要真高清请改内部构图分辨率,"
2008
+ "而不是放大输出。" % (ow, oh, W, H))
2009
+ return dict(fps=fps, w=W, h=H, out_w=ow, out_h=oh, scale=True,
2010
+ label="%dx%d" % (ow, oh))
2011
+
2012
+
1393
2013
  # ---------------------------------------------------------------- 时间轴
1394
2014
  def build_timeline(lines, lead=0.55, tail=0.85, min_dur=3.0, tail_map=None, max_dur=None,
1395
- hold=0.0, hold_map=None, bar=0.0, lead_map=None):
2015
+ hold=0.0, hold_map=None, bar=0.0, lead_map=None, fps=None):
1396
2016
  """由实测语音时长反推时间轴。第一段 start 必须严格为 0。
1397
2017
 
1398
2018
  hold / hold_map(v0.3.0):语音结束后额外停留的秒数——这就是 ai-history-film 的"停留窗口":
1399
2019
  观众没看清的地方加停留,而不是把整片放慢。hold 为全局默认,hold_map 按 id 覆盖,
1400
2020
  行内 `hold` 字段优先级最高。bar > 0 时把每段时长向上取整到 bar 的整数倍
1401
2021
  (配乐小节对齐用,例如 bar=2.0 = 120 BPM 的一小节)。
2022
+
2023
+ max_dur 只能截留白,不能截到语音结束之前:段时长被压到 lead+语音以下时,场景变短
2024
+ 而旁白仍按 voice_start 播完整长句 -> 下一句的语音压在本句上(音频总线里两条人声
2025
+ 重叠),字幕窗口也会倒挂。所以取 max(max_dur, 硬下限) 并打印 WARN;真要缩短请减
2026
+ 句长或改 lead/tail。
1402
2027
  """
1403
2028
  scenes, t = [], 0.0
1404
2029
  for ln in lines:
@@ -1410,16 +2035,33 @@ def build_timeline(lines, lead=0.55, tail=0.85, min_dur=3.0, tail_map=None, max_
1410
2035
  tail_i = float((tail_map or {}).get(ln["id"], tail))
1411
2036
  d = max(float(min_dur), lead_i + voice + tail_i + extra)
1412
2037
  if max_dur and d > float(max_dur):
1413
- d = float(max_dur)
2038
+ # 硬下限 = min_dur 与 "句前留白 + 语音" 的较大者:min_dur 是"画面撑住"的下限,
2039
+ # lead+voice 是"人声不被切断/不与下一句重叠"的物理下限。max_dur 只压得动
2040
+ # tail/hold 这些留白;压到语音以下时必须报警(静默重叠比参数没生效更糟)。
2041
+ floor = max(float(min_dur), lead_i + voice)
2042
+ d = max(float(max_dur), floor)
2043
+ if d > float(max_dur) + 1e-9:
2044
+ print(" [WARN] build_timeline: %s 的 lead %.2fs + 语音 %.2fs 超过 "
2045
+ "max_dur=%.2fs,段时长取 %.2fs(不切断旁白;要更短请减句长或调 lead)"
2046
+ % (ln.get("id", "?"), lead_i, voice, float(max_dur), d), flush=True)
1414
2047
  if bar and float(bar) > 0:
1415
2048
  d = math.ceil(d / float(bar) - 1e-9) * float(bar)
1416
- scenes.append(dict(ln, start=round(t, 3), end=round(t + d, 3), dur=round(d, 3),
1417
- voice_start=round(t + lead_i, 3),
1418
- voice_end=round(t + lead_i + voice, 3)))
2049
+ row = dict(ln, start=round(t, 3), end=round(t + d, 3), dur=round(d, 3),
2050
+ voice_start=round(t + lead_i, 3),
2051
+ voice_end=round(t + lead_i + voice, 3))
2052
+ # v0.8:把句内词表平移成**绝对时间**,下游(字幕断行 / cue 词锚点 /
2053
+ # 词级门禁)只需要读 words_abs,不必自己再算一遍 offset。
2054
+ if ln.get("words"):
2055
+ vs = row["voice_start"]
2056
+ row["words_abs"] = [dict(w, start=round(vs + float(w["start"]), 4),
2057
+ end=round(vs + float(w["end"]), 4))
2058
+ for w in ln["words"]]
2059
+ scenes.append(row)
1419
2060
  t += d
1420
2061
  if scenes:
1421
2062
  scenes[0]["start"] = 0.0
1422
- return dict(total=round(t, 3), scenes=scenes, fps=FPS, frames=int(round(t * FPS)))
2063
+ f = int(fps or FPS)
2064
+ return dict(total=round(t, 3), scenes=scenes, fps=f, frames=int(round(t * f)))
1423
2065
 
1424
2066
 
1425
2067
  def sub_window(scenes, i, max_hold=None):
@@ -1431,7 +2073,9 @@ def sub_window(scenes, i, max_hold=None):
1431
2073
  s = scenes[i]
1432
2074
  t0 = s["voice_start"] - 0.12
1433
2075
  t1 = s["voice_end"] + 0.45
1434
- if max_hold:
2076
+ if max_hold and float(max_hold) > 0:
2077
+ # 0/负值当"不限制":旧实现传 -0.5 会把 t1 钳到 voice_start-0.5,窗口倒挂、
2078
+ # 整句字幕消失。sub_max_hold 是公共参数,外部调用可能原样透传。
1435
2079
  t1 = min(t1, s["voice_start"] + float(max_hold))
1436
2080
  t1 = min(t1, s["end"] - 0.06)
1437
2081
  nxt = scenes[i + 1]["voice_start"] - 0.12 if i + 1 < len(scenes) else 1e9
@@ -1446,6 +2090,15 @@ def sub_at(scenes, t, max_hold=None):
1446
2090
  return ""
1447
2091
 
1448
2092
 
2093
+ def sub_words_at(scenes, t, max_hold=None):
2094
+ """取当前时刻上屏字幕对应的**词表**(v0.8);没有词级就返回 None。"""
2095
+ for i, s in enumerate(scenes):
2096
+ t0, t1 = sub_window(scenes, i, max_hold)
2097
+ if t0 <= t <= t1:
2098
+ return s.get("words_abs") or None
2099
+ return None
2100
+
2101
+
1449
2102
  def write_srt(scenes, path, max_hold=None):
1450
2103
  def fmt(x):
1451
2104
  ms = int(round(x * 1000))
@@ -1457,6 +2110,10 @@ def write_srt(scenes, path, max_hold=None):
1457
2110
  rows = []
1458
2111
  for i, s in enumerate(scenes):
1459
2112
  t0, t1 = sub_window(scenes, i, max_hold)
2113
+ # 时间码不能为负、不能倒挂:首句 lead < 0.12 时 t0 = -0.12,直接写会得到
2114
+ # "-1:59:59,880" 这种非法行(播放器整条丢弃或直接报错)。
2115
+ t0 = max(0.0, t0)
2116
+ t1 = max(t0, t1)
1460
2117
  body = s.get("sub") or s.get("text", "")
1461
2118
  rows.append("%d\n%s --> %s\n%s\n" % (i + 1, fmt(t0), fmt(t1), body))
1462
2119
  d = os.path.dirname(os.path.abspath(path))
@@ -1513,6 +2170,9 @@ def write_ass(scenes, path, max_hold=None, font_name="Microsoft YaHei", size=52,
1513
2170
  body = []
1514
2171
  for i, s in enumerate(scenes):
1515
2172
  t0, t1 = sub_window(scenes, i, max_hold)
2173
+ # 同 write_srt:ASS 的 &H 时间码同样不接受负值(-0.12s 会写成 -1:59:59.88)。
2174
+ t0 = max(0.0, t0)
2175
+ t1 = max(t0, t1)
1516
2176
  txt = (s.get("sub") or s.get("text", "")).replace("\n", "\\N")
1517
2177
  body.append("Dialogue: 0,%s,%s,Default,,0,0,0,,%s" % (fmt(t0), fmt(t1), txt))
1518
2178
  d = os.path.dirname(os.path.abspath(path))
@@ -1531,11 +2191,21 @@ class FrameSink:
1531
2191
  """
1532
2192
 
1533
2193
  def __init__(self, out_path, w=W, h=H, fps=FPS, crf=18, preset="medium",
1534
- audio=None, bitrate=None, strict=True, extra_args=None):
2194
+ audio=None, bitrate=None, strict=True, extra_args=None,
2195
+ out_w=None, out_h=None, scale_flags="lanczos"):
1535
2196
  self.out = str(out_path)
1536
2197
  self.w, self.h, self.fps = int(w), int(h), float(fps)
1537
2198
  self.strict = strict
1538
2199
  self.frames = 0
2200
+ # v0.8:内部构图恒为 w x h(PIL 画布),输出可以更小(只允许下采样)。
2201
+ # 缩放放在**编码这一遍**做(-vf scale),不二次编码、也不改变合成内容:
2202
+ # 1920→1280 的 lanczos 下采样本身等于 1.5x 超采样,画面不会因为降规格而变糊。
2203
+ self.out_w = int(out_w or self.w)
2204
+ self.out_h = int(out_h or self.h)
2205
+ if self.out_w > self.w or self.out_h > self.h:
2206
+ raise ValueError("FrameSink: 输出 %dx%d 大于内部构图 %dx%d,拒绝上采样"
2207
+ % (self.out_w, self.out_h, self.w, self.h))
2208
+ self.scale = (self.out_w, self.out_h) != (self.w, self.h)
1539
2209
  d = os.path.dirname(os.path.abspath(self.out))
1540
2210
  if d:
1541
2211
  os.makedirs(d, exist_ok=True)
@@ -1544,6 +2214,8 @@ class FrameSink:
1544
2214
  "-r", "%g" % self.fps, "-i", "-"]
1545
2215
  if audio:
1546
2216
  cmd += ["-i", str(audio)]
2217
+ if self.scale:
2218
+ cmd += ["-vf", "scale=%d:%d:flags=%s" % (self.out_w, self.out_h, scale_flags)]
1547
2219
  cmd += ["-c:v", "libx264", "-preset", preset, "-crf", str(crf),
1548
2220
  "-pix_fmt", "yuv420p", "-g", str(int(self.fps * 2)),
1549
2221
  "-keyint_min", str(int(self.fps * 2)), "-sc_threshold", "0"]
@@ -1575,11 +2247,19 @@ class FrameSink:
1575
2247
  self.frames += 1
1576
2248
 
1577
2249
  def close(self):
1578
- self.proc.stdin.close()
2250
+ try:
2251
+ self.proc.stdin.close()
2252
+ except (BrokenPipeError, OSError):
2253
+ # 编码进程已经死了:真正的死因在 stderr 里,下面统一报,别让 BrokenPipe
2254
+ # 把 ffmpeg 的错误信息顶掉(旧实现只有裸 close,异常时看不到任何诊断)。
2255
+ pass
2256
+ # 先排空 stderr 再 wait:-v error 的报错行也可能多到写满 64KB 管道,
2257
+ # 而 wait() 期间没人读它 -> ffmpeg 卡在写 stderr、双方一起死等。
2258
+ err = self.proc.stderr.read() if self.proc.stderr is not None else b""
1579
2259
  rc = self.proc.wait()
1580
2260
  if rc != 0:
1581
- raise RuntimeError("ffmpeg 编码失败: "
1582
- + self.proc.stderr.read().decode("utf-8", "replace")[:800])
2261
+ raise RuntimeError("ffmpeg 编码失败(exitcode=%d): %s"
2262
+ % (rc, err.decode("utf-8", "replace")[:800]))
1583
2263
  return self.out
1584
2264
 
1585
2265
 
@@ -1619,8 +2299,8 @@ def mux(video, audio, out, crf=18, preset="medium", audio_bitrate="192k",
1619
2299
  af = loudnorm_two_pass(audio, target_i=target_i, target_tp=target_tp) if loudnorm else None
1620
2300
  if loudnorm and not af:
1621
2301
  # 不静默降级:量不到就喊出来(qa 的响度门禁还在,但这里先说清楚)
1622
- print(" [WARN] loudnorm 两遍测量失败(ffmpeg 输出里没解析到 JSON),本次不做响度标准化",
1623
- flush=True)
2302
+ print(" [WARN] loudnorm 两遍测量失败(拿不到可用测量值:全静音/极短音频会给出 -inf),"
2303
+ "本次不做响度标准化", flush=True)
1624
2304
 
1625
2305
  vd = media_dur(video)
1626
2306
  ad = media_dur(audio)
@@ -1658,6 +2338,17 @@ def loudnorm_two_pass(src, target_i=-16.0, target_tp=-1.5, target_lra=11.0):
1658
2338
  j = json.loads(m.group(0))
1659
2339
  except Exception:
1660
2340
  return None
2341
+ # 全静音 / 极短音频时 ffmpeg 会报 input_i=-inf、target_offset=inf:这些值塞进
2342
+ # measured_* 会被 ffmpeg 拒绝("Value -inf for parameter 'measured_I' out of range"),
2343
+ # mux 直接失败(旧实现只判"解析到 JSON",于是拿着 -inf 的滤镜串去编码)。
2344
+ # 量不到可用测量值就返回 None:mux 会打 WARN 跳过响度标准化,qa 的响度门禁照旧兜底。
2345
+ need = ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")
2346
+ try:
2347
+ bad = [k for k in need if not math.isfinite(float(j.get(k, 0.0)))]
2348
+ except (TypeError, ValueError):
2349
+ return None
2350
+ if bad:
2351
+ return None
1661
2352
  return ("loudnorm=I=%.1f:TP=%.1f:LRA=%.1f:measured_I=%s:measured_TP=%s:"
1662
2353
  "measured_LRA=%s:measured_thresh=%s:offset=%s:print_format=summary"
1663
2354
  % (target_i, target_tp, target_lra, j.get("input_i"), j.get("input_tp"),
@@ -1683,9 +2374,15 @@ def loudness(path):
1683
2374
 
1684
2375
 
1685
2376
  def detect_black(path, dur=0.4, head=1.2, tail=None):
1686
- """长黑帧检测。head/tail 秒内是刻意的淡入淡出,不算问题。"""
2377
+ """长黑帧检测。head/tail 秒内是刻意的淡入淡出,不算问题。
2378
+
2379
+ 返回 None 表示**检测根本没跑成**(ffmpeg 打不开文件 / blackdetect 失败)。
2380
+ 这一点必须可区分:旧实现把失败也返回 0,qa 门禁于是把"没查"记成"0 段黑帧 = 通过"。
2381
+ """
1687
2382
  p = _run([FFMPEG, "-hide_banner", "-nostats", "-i", str(path), "-vf",
1688
2383
  "blackdetect=d=%s:pic_th=0.98" % dur, "-f", "null", "-"])
2384
+ if p.returncode != 0:
2385
+ return None
1689
2386
  total = media_dur(path)
1690
2387
  tl = tail if tail is not None else head
1691
2388
  bad = 0
@@ -1698,8 +2395,11 @@ def detect_black(path, dur=0.4, head=1.2, tail=None):
1698
2395
 
1699
2396
 
1700
2397
  def detect_silence(path, th=-45, dur=1.2):
2398
+ """长静音段计数。返回 None = 检测没跑成(理由同 detect_black,不许当 0 计入通过)。"""
1701
2399
  p = _run([FFMPEG, "-hide_banner", "-nostats", "-i", str(path), "-af",
1702
2400
  "silencedetect=n=%ddB:d=%s" % (th, dur), "-f", "null", "-"])
2401
+ if p.returncode != 0:
2402
+ return None
1703
2403
  return len(re.findall("silence_start", p.stderr.decode("utf-8", "replace")))
1704
2404
 
1705
2405
 
@@ -1786,6 +2486,11 @@ def encode_quality(film, ref_png, at_sec=None, ref_img=None):
1786
2486
  dec = cv2.imread(tmp, cv2.IMREAD_GRAYSCALE)
1787
2487
  if dec is None:
1788
2488
  return None
2489
+ if ref.shape != dec.shape:
2490
+ # v0.8:输出降到 720p 时,参考帧还是 1080p 的原图。不缩放就比 Laplacian 方差,
2491
+ # 等于拿两个不同分辨率的东西比"锐不锐"——ratio 会假性偏低(v0.8 前只在
2492
+ # 1920x1080 单一时是同一尺寸,所以没暴露)。这里统一缩到**交付分辨率**再比。
2493
+ ref = cv2.resize(ref, (dec.shape[1], dec.shape[0]), interpolation=cv2.INTER_AREA)
1789
2494
  sa = float(cv2.Laplacian(ref, cv2.CV_64F).var())
1790
2495
  sb = float(cv2.Laplacian(dec, cv2.CV_64F).var())
1791
2496
  return dict(sharp_ref=round(sa, 1), sharp_dec=round(sb, 1),
@@ -1872,10 +2577,15 @@ def _probe_concat_part(path):
1872
2577
  except Exception: # noqa: BLE001
1873
2578
  dur = 0.0
1874
2579
  a = next((s for s in streams if s.get("codec_type") == "audio"), None)
2580
+ # 音频参数也要探测:-c copy 拼接时各段采样率/声道不一致不会被 ffmpeg 校正,
2581
+ # 而是按参照段解释(实测 48k 段 + 44.1k 段拼完频率 440Hz -> 478.6Hz,
2582
+ # 即整段变调 1.086 倍,而 ffmpeg 返回码仍是 0)。
1875
2583
  return dict(ok=True, path=p, width=int(v.get("width") or 0),
1876
2584
  height=int(v.get("height") or 0), fps=round(fps, 3),
1877
2585
  fps_text=rate, codec=str(v.get("codec_name") or ""),
1878
- audio=bool(a), dur=dur)
2586
+ audio=bool(a), dur=dur,
2587
+ ar=int(a.get("sample_rate") or 0) if a else 0,
2588
+ channels=int(a.get("channels") or 0) if a else 0)
1879
2589
 
1880
2590
 
1881
2591
  def _fmt_fps(info):
@@ -1922,6 +2632,15 @@ def _concat_issues(parts):
1922
2632
  issues.append("%s%s音轨,%s%s音轨"
1923
2633
  % (who, "有" if inf["audio"] else "无",
1924
2634
  base, "有" if r["audio"] else "无"))
2635
+ elif inf["audio"] and r["audio"]:
2636
+ # 有音轨时再比音频参数:copy 不做重采样/重混,参数不一致 ->
2637
+ # 音高/时长/声道被按参照段解释(错片但返回码 0,属于"静默错",必须走重编码)。
2638
+ if inf.get("ar") and r.get("ar") and inf["ar"] != r["ar"]:
2639
+ issues.append("%s音频采样率 %d Hz != %s %d Hz(-c copy 会让音高/时长错位)"
2640
+ % (who, inf["ar"], base, r["ar"]))
2641
+ if inf.get("channels") and r.get("channels") and inf["channels"] != r["channels"]:
2642
+ issues.append("%s音频声道数 %d != %s %d"
2643
+ % (who, inf["channels"], base, r["channels"]))
1925
2644
  return infos, issues, ref
1926
2645
 
1927
2646
 
@@ -1974,7 +2693,12 @@ def _concat_reencode(parts, out, infos, ref, lst):
1974
2693
  fc.append("[%d:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[a%d]"
1975
2694
  % (i, i))
1976
2695
  else:
1977
- dur = max(float(inf.get("dur") or 0), ref_dur, 0.1)
2696
+ # 静音长度必须等于**这一段自己的**视频长度。旧实现取 max(段长, 参照段长):
2697
+ # 参照段更长时会给短的无音轨段多补几秒静音,拼接后音频比视频长
2698
+ # (实测 3s 有声 + 1s 无声 -> 输出 6s,视频只有 4s,后半段音画差 2s)。
2699
+ # 段长读不出来(ffprobe 没有 duration)时才退回参照段时长。
2700
+ seg_dur = max(float(inf.get("dur") or 0), 0.0)
2701
+ dur = max(seg_dur if seg_dur > 0 else ref_dur, 0.1)
1978
2702
  fc.append("anullsrc=r=48000:cl=stereo,atrim=duration=%.3f,asetpts=PTS-STARTPTS[a%d]"
1979
2703
  % (dur, i))
1980
2704
  labels = "".join("[v%d]%s" % (i, "[a%d]" % i if any_audio else "")
@@ -2065,15 +2789,22 @@ def concat_segments(parts, out, workdir=None):
2065
2789
 
2066
2790
  # ---------------------------------------------------------------- 总质检
2067
2791
  def qa_report(path, expect_frames=None, ref_png=None, ref_sec=None, ref_img=None,
2068
- expected_subtitles=None, min_bitrate=0, notes=None, theme=None):
2792
+ expected_subtitles=None, min_bitrate=0, notes=None, theme=None,
2793
+ expect_w=None, expect_h=None, expect_fps=None):
2069
2794
  """分档质检:errors 必须修,warnings 看情况。
2070
2795
 
2071
2796
  v0.2 修正:
2072
2797
  - 真峰现在能测到(loudness 加 peak=true),<= -1 dBTP 才是真门禁;
2073
2798
  - 码率默认不再当"下限"(暗色简洁内容本来就只有 0.5Mbps,v0.1 会误判);
2074
2799
  - 新增字幕条数校验(expected_subtitles),这是"静默丢字幕"的唯一检查点。
2800
+
2801
+ v0.8:交付规格可配置(expect_w/expect_h/expect_fps)。以前这里死比 K.W/K.H/K.FPS,
2802
+ 于是 720p/24fps 的合法交付会被报成"分辨率/帧率不符"——门禁判据跟交付规格脱节,
2803
+ 比误报更糟的是它会让人去"修"一个根本没错的东西。缺省仍回落到模块常量(老行为不变)。
2075
2804
  """
2076
2805
  errors, warns = [], []
2806
+ W, H, FPS = (int(expect_w or globals()["W"]), int(expect_h or globals()["H"]),
2807
+ float(expect_fps or globals()["FPS"]))
2077
2808
  pr = probe(path)
2078
2809
  fmt = pr.get("format", {})
2079
2810
  streams = pr.get("streams") or [{}]
@@ -2088,7 +2819,7 @@ def qa_report(path, expect_frames=None, ref_png=None, ref_sec=None, ref_img=None
2088
2819
  num, _, den = rate.partition("/")
2089
2820
  got = float(num) / float(den or 1)
2090
2821
  if abs(got - float(FPS)) > 0.05:
2091
- errors.append("帧率 %.3f != %d(SKILL 要求严格一致)" % (got, FPS))
2822
+ errors.append("帧率 %.3f != %g(SKILL 要求严格一致)" % (got, FPS))
2092
2823
  except Exception:
2093
2824
  pass
2094
2825
  if not au:
@@ -2123,9 +2854,14 @@ def qa_report(path, expect_frames=None, ref_png=None, ref_sec=None, ref_img=None
2123
2854
  else:
2124
2855
  warns.append("未能测到真峰(ebur128 解析失败),责任门禁缺一项")
2125
2856
  nb = detect_black(path)
2126
- if nb > 0:
2857
+ if nb is None:
2858
+ warns.append("长黑帧检测未执行(ffmpeg blackdetect 失败),本项不能当作通过")
2859
+ elif nb > 0:
2127
2860
  warns.append("首尾之外检测到 %d 段长黑帧" % nb)
2128
- if detect_silence(path) > 0:
2861
+ ns = detect_silence(path)
2862
+ if ns is None:
2863
+ warns.append("长静音检测未执行(ffmpeg silencedetect 失败),本项不能当作通过")
2864
+ elif ns > 0:
2129
2865
  warns.append("检测到长静音段(确认是否有意留白)")
2130
2866
  if isinstance(theme, dict):
2131
2867
  # 主题体检的结论带进报告:这样"深色主题必然贴空窗阈值"这种事实不是靠嘴说