@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.
- package/CHANGELOG.md +951 -0
- package/README.md +51 -8
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +4 -1
- package/package.json +5 -3
- package/schemas/film.schema.json +162 -2
- package/skills/video-studio/SKILL.md +211 -11
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/web/engine.js +63 -2
- package/skills/video-studio/assets/web/film.js +224 -101
- package/skills/video-studio/assets/web/index.html +4 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +4 -3
- package/skills/video-studio/references/audio.md +2 -2
- package/skills/video-studio/references/change-impact.md +1 -1
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +14 -0
- package/skills/video-studio/scripts/filmcheck.py +121 -0
- package/skills/video-studio/scripts/filmkit.py +521 -38
- package/skills/video-studio/scripts/gates.py +88 -4
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +49 -8
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +719 -32
- package/skills/video-studio/scripts/scorekit.py +16 -30
- package/skills/video-studio/scripts/selfcheck.py +274 -0
- package/skills/video-studio/scripts/sfxkit.py +130 -14
- package/skills/video-studio/scripts/stagewatch.py +19 -6
- package/skills/video-studio/scripts/stylekit.py +43 -12
- package/skills/video-studio/scripts/voicekit.py +15 -7
- package/skills/video-studio/scripts/webengine.py +1119 -72
- package/tools/ci_check.py +171 -31
- package/tools/estimate.py +130 -19
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
1803
|
+
if _words_ok(m):
|
|
1804
|
+
return m
|
|
1805
|
+
audio_cached = m # 有音频、缺词级:断网时降级复用它
|
|
1543
1806
|
if not m.get("trimmed") and not trim:
|
|
1544
|
-
|
|
1807
|
+
if _words_ok(m):
|
|
1808
|
+
return m
|
|
1809
|
+
audio_cached = m
|
|
1545
1810
|
# v1 老缓存(升级前合成的)只有在参数完全等于 v1 语义时才复用,
|
|
1546
1811
|
# 避免"改了一个 volume 却拿到老音频"这种升级期的静默错配。
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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
|
-
|
|
1564
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 != %
|
|
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
|
|
2857
|
+
if nb is None:
|
|
2858
|
+
warns.append("长黑帧检测未执行(ffmpeg blackdetect 失败),本项不能当作通过")
|
|
2859
|
+
elif nb > 0:
|
|
2380
2860
|
warns.append("首尾之外检测到 %d 段长黑帧" % nb)
|
|
2381
|
-
|
|
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
|
# 主题体检的结论带进报告:这样"深色主题必然贴空窗阈值"这种事实不是靠嘴说
|