@dsh-gis/dsh-video-studio-skill 0.7.2 → 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 (39) hide show
  1. package/CHANGELOG.md +951 -0
  2. package/README.md +51 -8
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/index.js +4 -1
  5. package/package.json +5 -3
  6. package/schemas/film.schema.json +162 -2
  7. package/skills/video-studio/SKILL.md +211 -11
  8. package/skills/video-studio/assets/golden_frames.json +65 -0
  9. package/skills/video-studio/assets/web/engine.js +63 -2
  10. package/skills/video-studio/assets/web/film.js +224 -101
  11. package/skills/video-studio/assets/web/index.html +4 -2
  12. package/skills/video-studio/assets/web/qa_text.js +36 -8
  13. package/skills/video-studio/references/audio-variation.md +4 -3
  14. package/skills/video-studio/references/audio.md +2 -2
  15. package/skills/video-studio/references/change-impact.md +1 -1
  16. package/skills/video-studio/references/quality-gates.md +25 -3
  17. package/skills/video-studio/references/style-packs.md +7 -3
  18. package/skills/video-studio/references/v08-quality.md +256 -0
  19. package/skills/video-studio/references/web-engine.md +19 -2
  20. package/skills/video-studio/references/word-timeline.md +147 -0
  21. package/skills/video-studio/requirements.txt +14 -0
  22. package/skills/video-studio/scripts/filmcheck.py +121 -0
  23. package/skills/video-studio/scripts/filmkit.py +521 -38
  24. package/skills/video-studio/scripts/gates.py +88 -4
  25. package/skills/video-studio/scripts/preflight.py +774 -0
  26. package/skills/video-studio/scripts/qa_frames.py +49 -8
  27. package/skills/video-studio/scripts/qaasr.py +1563 -0
  28. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  29. package/skills/video-studio/scripts/render.py +719 -32
  30. package/skills/video-studio/scripts/scorekit.py +16 -30
  31. package/skills/video-studio/scripts/selfcheck.py +274 -0
  32. package/skills/video-studio/scripts/sfxkit.py +130 -14
  33. package/skills/video-studio/scripts/stagewatch.py +19 -6
  34. package/skills/video-studio/scripts/stylekit.py +43 -12
  35. package/skills/video-studio/scripts/voicekit.py +15 -7
  36. package/skills/video-studio/scripts/webengine.py +1119 -72
  37. package/tools/ci_check.py +171 -31
  38. package/tools/estimate.py +130 -19
  39. package/tools/golden_frames.py +664 -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
@@ -821,10 +886,13 @@ CUE_BASE_DB = {
821
886
  def cue_time(at, scenes=None, total=None):
822
887
  """把提示点的 at 解析成成片绝对秒。
823
888
 
824
- 支持三种写法(CONTRACT.md §4.1):
889
+ 支持四种写法(CONTRACT.md §4.1):
825
890
  "abs:12.5" / 12.5 -> 绝对秒
826
891
  "l03+0.35" / "l03-0.2" -> 场景 id 加/减偏移(相对场景 start)
827
892
  "l03" -> 场景起点
893
+ "l03:w2" / "l03:w2+0.1" -> **词锚点**(v0.8):该句第 2 个词的起点 + 偏移。
894
+ 下标从 1 开始(w1 = 第一个词);找不到该词时**退化为句偏移**并告警,
895
+ 不让一个写错的锚点把整条 cues 链路炸掉(与 v0.7.2 的"跳过并留痕"一致)。
828
896
  """
829
897
  if isinstance(at, (int, float)):
830
898
  return round(float(at), 3)
@@ -837,6 +905,32 @@ def cue_time(at, scenes=None, total=None):
837
905
  return round(float(s), 3)
838
906
  except ValueError:
839
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))
840
934
  # 形如 <id>[+-]<offset>
841
935
  cut = len(s)
842
936
  for i, ch in enumerate(s):
@@ -884,6 +978,7 @@ class CueBus:
884
978
  self.scenes = scenes or []
885
979
  self.total = None if total is None else float(total)
886
980
  self.items = []
981
+ self._drop_warned = set() # 已告警过的丢弃项:同一实例只报一次(见 resolved)
887
982
 
888
983
  # -- 登记 --
889
984
  def add(self, at, type, **params):
@@ -909,15 +1004,36 @@ class CueBus:
909
1004
 
910
1005
  # -- 解析 --
911
1006
  def resolved(self):
912
- """按时间排序、去掉明显无效项(超过片长 2 秒的丢弃并告警)。"""
1007
+ """按时间排序、去掉明显无效项(超过片长 2 秒 / 负时刻),并**真的**告警。
1008
+
1009
+ 为什么必须告警:st_sfx 只把 build_audio 的产物当音效轨,被丢掉的 cue 不会进
1010
+ 任何报告;以前这里静默 continue(文档却写着"丢弃并告警"),少一个提示音时
1011
+ 用户只能靠听。st_sfx 会调两次 resolved()(自己一次 + build_audio 内部一次),
1012
+ 所以只在**第一次**发现丢弃时打一条汇总,避免同一件事刷两遍。
1013
+ """
913
1014
  out = []
1015
+ dropped = []
914
1016
  for row in self.items:
915
1017
  t = float(row["t"])
916
1018
  if t < -0.05:
1019
+ dropped.append(("负时刻", t))
917
1020
  continue
918
1021
  if self.total is not None and t > self.total + 2.0:
1022
+ dropped.append(("超出片长", t))
919
1023
  continue
920
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)
921
1037
  return sorted(out, key=lambda r: float(r["t"]))
922
1038
 
923
1039
  def to_json(self, duration=None, fps=None, width=None, height=None, events=None):
@@ -1513,9 +1629,127 @@ def tts_key_v2(text, voice, rate, pitch, volume="+0%", trim=True, trim_db=-42.0,
1513
1629
  return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16]
1514
1630
 
1515
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
+
1516
1750
  def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1517
1751
  cache=True, retries=4, trim=True, trim_db=-42.0, volume="+0%",
1518
- persona=None, tone=None):
1752
+ persona=None, tone=None, words=False, backend="edge"):
1519
1753
  """逐句合成旁白(带输入校验的缓存 + 网络重试 + 可选尾静音裁剪)。
1520
1754
 
1521
1755
  v0.1 的缓存只判断文件是否存在,改一句台词会静默复用旧音频;
@@ -1528,25 +1762,59 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1528
1762
  d = os.path.dirname(os.path.abspath(out_mp3))
1529
1763
  if d:
1530
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
1531
1782
  meta_path = out_mp3 + ".json"
1532
1783
  vol = str(volume or "+0%")
1533
1784
  key = tts_key_v2(text, voice, rate, pitch, volume=vol, trim=trim, trim_db=trim_db)
1534
1785
  legacy_key = tts_key(text, voice, rate, pitch)
1535
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 # 可用音频(可能缺词级记录)
1536
1797
  if cache and os.path.exists(out_mp3) and os.path.getsize(out_mp3) > 2000:
1537
1798
  try:
1538
1799
  m = json.load(open(meta_path, encoding="utf-8"))
1539
1800
  # v2 命中:key 已经含 volume/trim,不需要再分叉判断 trimmed
1540
1801
  if m.get("key") == key and m.get("text") == text:
1541
1802
  if m.get("trimmed") and m.get("path") and os.path.exists(m["path"]):
1542
- return m
1803
+ if _words_ok(m):
1804
+ return m
1805
+ audio_cached = m # 有音频、缺词级:断网时降级复用它
1543
1806
  if not m.get("trimmed") and not trim:
1544
- return m
1807
+ if _words_ok(m):
1808
+ return m
1809
+ audio_cached = m
1545
1810
  # v1 老缓存(升级前合成的)只有在参数完全等于 v1 语义时才复用,
1546
1811
  # 避免"改了一个 volume 却拿到老音频"这种升级期的静默错配。
1547
- elif (legacy_key == tts_key(text, voice, rate, pitch)
1548
- and str(m.get("key") or "") == legacy_key and m.get("text") == text
1549
- 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)):
1550
1818
  if m.get("trimmed") and m.get("path") and os.path.exists(m["path"]):
1551
1819
  return m
1552
1820
  except Exception:
@@ -1555,13 +1823,41 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1555
1823
  import asyncio
1556
1824
  import time as _time
1557
1825
 
1826
+ raw_events = []
1558
1827
  for attempt in range(max(1, int(retries))):
1559
1828
  try:
1560
1829
  import edge_tts
1561
1830
 
1562
1831
  async def go():
1563
- c = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch, volume=vol)
1564
- 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
1565
1861
 
1566
1862
  asyncio.run(go())
1567
1863
  if not os.path.exists(out_mp3) or os.path.getsize(out_mp3) <= 2000:
@@ -1569,22 +1865,48 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1569
1865
  break
1570
1866
  except Exception as e: # noqa: BLE001
1571
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
1572
1878
  raise RuntimeError("edge-tts 合成失败(重试 %d 次):%s" % (retries, e)) from e
1573
1879
  _time.sleep(1.2 * (attempt + 1))
1574
1880
 
1575
- 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)
1576
1886
  used = out_mp3
1577
1887
  dur = dur_raw
1888
+ shift = 0.0 # 句首被裁掉的静音秒数(词时间戳要减掉它)
1578
1889
  if trim:
1579
- 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
1580
1892
  wav_path = os.path.splitext(out_mp3)[0] + ".trim.wav"
1581
1893
  wav_write(wav_path, pcm)
1582
1894
  used = wav_path
1583
1895
  dur = len(pcm) / float(SR)
1896
+ shift = a / float(SR) if pcm_raw.size else 0.0
1584
1897
 
1585
1898
  m = dict(path=used, mp3=out_mp3, text=text, voice=voice, rate=rate, pitch=pitch,
1586
1899
  volume=vol, key=key, spec_version=2, dur=dur, dur_raw=dur_raw,
1587
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)
1588
1910
  if persona:
1589
1911
  m["persona"] = str(persona)
1590
1912
  if tone:
@@ -1594,7 +1916,8 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1594
1916
 
1595
1917
 
1596
1918
  def tts_batch(lines, workdir, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1597
- 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"):
1598
1921
  """并发逐句合成(并发只影响墙钟,不影响产物:每句独立缓存)。
1599
1922
 
1600
1923
  v0.5.0:plan=[{voice,rate,pitch,volume,persona,tone}, ...](与 lines 等长)时
@@ -1617,7 +1940,7 @@ def tts_batch(lines, workdir, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0
1617
1940
  rate=spec.get("rate") or rate,
1618
1941
  pitch=spec.get("pitch") or pitch,
1619
1942
  volume=spec.get("volume") or "+0%",
1620
- trim=trim, trim_db=trim_db,
1943
+ trim=trim, trim_db=trim_db, words=words, backend=backend,
1621
1944
  persona=spec.get("persona"), tone=spec.get("tone"))
1622
1945
  row = dict(ln)
1623
1946
  row.update(m)
@@ -1632,15 +1955,75 @@ def tts_batch(lines, workdir, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0
1632
1955
  return results
1633
1956
 
1634
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
+
1635
2013
  # ---------------------------------------------------------------- 时间轴
1636
2014
  def build_timeline(lines, lead=0.55, tail=0.85, min_dur=3.0, tail_map=None, max_dur=None,
1637
- 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):
1638
2016
  """由实测语音时长反推时间轴。第一段 start 必须严格为 0。
1639
2017
 
1640
2018
  hold / hold_map(v0.3.0):语音结束后额外停留的秒数——这就是 ai-history-film 的"停留窗口":
1641
2019
  观众没看清的地方加停留,而不是把整片放慢。hold 为全局默认,hold_map 按 id 覆盖,
1642
2020
  行内 `hold` 字段优先级最高。bar > 0 时把每段时长向上取整到 bar 的整数倍
1643
2021
  (配乐小节对齐用,例如 bar=2.0 = 120 BPM 的一小节)。
2022
+
2023
+ max_dur 只能截留白,不能截到语音结束之前:段时长被压到 lead+语音以下时,场景变短
2024
+ 而旁白仍按 voice_start 播完整长句 -> 下一句的语音压在本句上(音频总线里两条人声
2025
+ 重叠),字幕窗口也会倒挂。所以取 max(max_dur, 硬下限) 并打印 WARN;真要缩短请减
2026
+ 句长或改 lead/tail。
1644
2027
  """
1645
2028
  scenes, t = [], 0.0
1646
2029
  for ln in lines:
@@ -1652,16 +2035,33 @@ def build_timeline(lines, lead=0.55, tail=0.85, min_dur=3.0, tail_map=None, max_
1652
2035
  tail_i = float((tail_map or {}).get(ln["id"], tail))
1653
2036
  d = max(float(min_dur), lead_i + voice + tail_i + extra)
1654
2037
  if max_dur and d > float(max_dur):
1655
- 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)
1656
2047
  if bar and float(bar) > 0:
1657
2048
  d = math.ceil(d / float(bar) - 1e-9) * float(bar)
1658
- scenes.append(dict(ln, start=round(t, 3), end=round(t + d, 3), dur=round(d, 3),
1659
- voice_start=round(t + lead_i, 3),
1660
- 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)
1661
2060
  t += d
1662
2061
  if scenes:
1663
2062
  scenes[0]["start"] = 0.0
1664
- 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)))
1665
2065
 
1666
2066
 
1667
2067
  def sub_window(scenes, i, max_hold=None):
@@ -1673,7 +2073,9 @@ def sub_window(scenes, i, max_hold=None):
1673
2073
  s = scenes[i]
1674
2074
  t0 = s["voice_start"] - 0.12
1675
2075
  t1 = s["voice_end"] + 0.45
1676
- 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 是公共参数,外部调用可能原样透传。
1677
2079
  t1 = min(t1, s["voice_start"] + float(max_hold))
1678
2080
  t1 = min(t1, s["end"] - 0.06)
1679
2081
  nxt = scenes[i + 1]["voice_start"] - 0.12 if i + 1 < len(scenes) else 1e9
@@ -1688,6 +2090,15 @@ def sub_at(scenes, t, max_hold=None):
1688
2090
  return ""
1689
2091
 
1690
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
+
1691
2102
  def write_srt(scenes, path, max_hold=None):
1692
2103
  def fmt(x):
1693
2104
  ms = int(round(x * 1000))
@@ -1699,6 +2110,10 @@ def write_srt(scenes, path, max_hold=None):
1699
2110
  rows = []
1700
2111
  for i, s in enumerate(scenes):
1701
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)
1702
2117
  body = s.get("sub") or s.get("text", "")
1703
2118
  rows.append("%d\n%s --> %s\n%s\n" % (i + 1, fmt(t0), fmt(t1), body))
1704
2119
  d = os.path.dirname(os.path.abspath(path))
@@ -1755,6 +2170,9 @@ def write_ass(scenes, path, max_hold=None, font_name="Microsoft YaHei", size=52,
1755
2170
  body = []
1756
2171
  for i, s in enumerate(scenes):
1757
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)
1758
2176
  txt = (s.get("sub") or s.get("text", "")).replace("\n", "\\N")
1759
2177
  body.append("Dialogue: 0,%s,%s,Default,,0,0,0,,%s" % (fmt(t0), fmt(t1), txt))
1760
2178
  d = os.path.dirname(os.path.abspath(path))
@@ -1773,11 +2191,21 @@ class FrameSink:
1773
2191
  """
1774
2192
 
1775
2193
  def __init__(self, out_path, w=W, h=H, fps=FPS, crf=18, preset="medium",
1776
- 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"):
1777
2196
  self.out = str(out_path)
1778
2197
  self.w, self.h, self.fps = int(w), int(h), float(fps)
1779
2198
  self.strict = strict
1780
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)
1781
2209
  d = os.path.dirname(os.path.abspath(self.out))
1782
2210
  if d:
1783
2211
  os.makedirs(d, exist_ok=True)
@@ -1786,6 +2214,8 @@ class FrameSink:
1786
2214
  "-r", "%g" % self.fps, "-i", "-"]
1787
2215
  if audio:
1788
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)]
1789
2219
  cmd += ["-c:v", "libx264", "-preset", preset, "-crf", str(crf),
1790
2220
  "-pix_fmt", "yuv420p", "-g", str(int(self.fps * 2)),
1791
2221
  "-keyint_min", str(int(self.fps * 2)), "-sc_threshold", "0"]
@@ -1817,11 +2247,19 @@ class FrameSink:
1817
2247
  self.frames += 1
1818
2248
 
1819
2249
  def close(self):
1820
- 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""
1821
2259
  rc = self.proc.wait()
1822
2260
  if rc != 0:
1823
- raise RuntimeError("ffmpeg 编码失败: "
1824
- + self.proc.stderr.read().decode("utf-8", "replace")[:800])
2261
+ raise RuntimeError("ffmpeg 编码失败(exitcode=%d): %s"
2262
+ % (rc, err.decode("utf-8", "replace")[:800]))
1825
2263
  return self.out
1826
2264
 
1827
2265
 
@@ -1936,9 +2374,15 @@ def loudness(path):
1936
2374
 
1937
2375
 
1938
2376
  def detect_black(path, dur=0.4, head=1.2, tail=None):
1939
- """长黑帧检测。head/tail 秒内是刻意的淡入淡出,不算问题。"""
2377
+ """长黑帧检测。head/tail 秒内是刻意的淡入淡出,不算问题。
2378
+
2379
+ 返回 None 表示**检测根本没跑成**(ffmpeg 打不开文件 / blackdetect 失败)。
2380
+ 这一点必须可区分:旧实现把失败也返回 0,qa 门禁于是把"没查"记成"0 段黑帧 = 通过"。
2381
+ """
1940
2382
  p = _run([FFMPEG, "-hide_banner", "-nostats", "-i", str(path), "-vf",
1941
2383
  "blackdetect=d=%s:pic_th=0.98" % dur, "-f", "null", "-"])
2384
+ if p.returncode != 0:
2385
+ return None
1942
2386
  total = media_dur(path)
1943
2387
  tl = tail if tail is not None else head
1944
2388
  bad = 0
@@ -1951,8 +2395,11 @@ def detect_black(path, dur=0.4, head=1.2, tail=None):
1951
2395
 
1952
2396
 
1953
2397
  def detect_silence(path, th=-45, dur=1.2):
2398
+ """长静音段计数。返回 None = 检测没跑成(理由同 detect_black,不许当 0 计入通过)。"""
1954
2399
  p = _run([FFMPEG, "-hide_banner", "-nostats", "-i", str(path), "-af",
1955
2400
  "silencedetect=n=%ddB:d=%s" % (th, dur), "-f", "null", "-"])
2401
+ if p.returncode != 0:
2402
+ return None
1956
2403
  return len(re.findall("silence_start", p.stderr.decode("utf-8", "replace")))
1957
2404
 
1958
2405
 
@@ -2039,6 +2486,11 @@ def encode_quality(film, ref_png, at_sec=None, ref_img=None):
2039
2486
  dec = cv2.imread(tmp, cv2.IMREAD_GRAYSCALE)
2040
2487
  if dec is None:
2041
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)
2042
2494
  sa = float(cv2.Laplacian(ref, cv2.CV_64F).var())
2043
2495
  sb = float(cv2.Laplacian(dec, cv2.CV_64F).var())
2044
2496
  return dict(sharp_ref=round(sa, 1), sharp_dec=round(sb, 1),
@@ -2125,10 +2577,15 @@ def _probe_concat_part(path):
2125
2577
  except Exception: # noqa: BLE001
2126
2578
  dur = 0.0
2127
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)。
2128
2583
  return dict(ok=True, path=p, width=int(v.get("width") or 0),
2129
2584
  height=int(v.get("height") or 0), fps=round(fps, 3),
2130
2585
  fps_text=rate, codec=str(v.get("codec_name") or ""),
2131
- 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)
2132
2589
 
2133
2590
 
2134
2591
  def _fmt_fps(info):
@@ -2175,6 +2632,15 @@ def _concat_issues(parts):
2175
2632
  issues.append("%s%s音轨,%s%s音轨"
2176
2633
  % (who, "有" if inf["audio"] else "无",
2177
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"]))
2178
2644
  return infos, issues, ref
2179
2645
 
2180
2646
 
@@ -2227,7 +2693,12 @@ def _concat_reencode(parts, out, infos, ref, lst):
2227
2693
  fc.append("[%d:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[a%d]"
2228
2694
  % (i, i))
2229
2695
  else:
2230
- 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)
2231
2702
  fc.append("anullsrc=r=48000:cl=stereo,atrim=duration=%.3f,asetpts=PTS-STARTPTS[a%d]"
2232
2703
  % (dur, i))
2233
2704
  labels = "".join("[v%d]%s" % (i, "[a%d]" % i if any_audio else "")
@@ -2318,15 +2789,22 @@ def concat_segments(parts, out, workdir=None):
2318
2789
 
2319
2790
  # ---------------------------------------------------------------- 总质检
2320
2791
  def qa_report(path, expect_frames=None, ref_png=None, ref_sec=None, ref_img=None,
2321
- 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):
2322
2794
  """分档质检:errors 必须修,warnings 看情况。
2323
2795
 
2324
2796
  v0.2 修正:
2325
2797
  - 真峰现在能测到(loudness 加 peak=true),<= -1 dBTP 才是真门禁;
2326
2798
  - 码率默认不再当"下限"(暗色简洁内容本来就只有 0.5Mbps,v0.1 会误判);
2327
2799
  - 新增字幕条数校验(expected_subtitles),这是"静默丢字幕"的唯一检查点。
2800
+
2801
+ v0.8:交付规格可配置(expect_w/expect_h/expect_fps)。以前这里死比 K.W/K.H/K.FPS,
2802
+ 于是 720p/24fps 的合法交付会被报成"分辨率/帧率不符"——门禁判据跟交付规格脱节,
2803
+ 比误报更糟的是它会让人去"修"一个根本没错的东西。缺省仍回落到模块常量(老行为不变)。
2328
2804
  """
2329
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"]))
2330
2808
  pr = probe(path)
2331
2809
  fmt = pr.get("format", {})
2332
2810
  streams = pr.get("streams") or [{}]
@@ -2341,7 +2819,7 @@ def qa_report(path, expect_frames=None, ref_png=None, ref_sec=None, ref_img=None
2341
2819
  num, _, den = rate.partition("/")
2342
2820
  got = float(num) / float(den or 1)
2343
2821
  if abs(got - float(FPS)) > 0.05:
2344
- errors.append("帧率 %.3f != %d(SKILL 要求严格一致)" % (got, FPS))
2822
+ errors.append("帧率 %.3f != %g(SKILL 要求严格一致)" % (got, FPS))
2345
2823
  except Exception:
2346
2824
  pass
2347
2825
  if not au:
@@ -2376,9 +2854,14 @@ def qa_report(path, expect_frames=None, ref_png=None, ref_sec=None, ref_img=None
2376
2854
  else:
2377
2855
  warns.append("未能测到真峰(ebur128 解析失败),责任门禁缺一项")
2378
2856
  nb = detect_black(path)
2379
- if nb > 0:
2857
+ if nb is None:
2858
+ warns.append("长黑帧检测未执行(ffmpeg blackdetect 失败),本项不能当作通过")
2859
+ elif nb > 0:
2380
2860
  warns.append("首尾之外检测到 %d 段长黑帧" % nb)
2381
- if detect_silence(path) > 0:
2861
+ ns = detect_silence(path)
2862
+ if ns is None:
2863
+ warns.append("长静音检测未执行(ffmpeg silencedetect 失败),本项不能当作通过")
2864
+ elif ns > 0:
2382
2865
  warns.append("检测到长静音段(确认是否有意留白)")
2383
2866
  if isinstance(theme, dict):
2384
2867
  # 主题体检的结论带进报告:这样"深色主题必然贴空窗阈值"这种事实不是靠嘴说