@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.
- package/CHANGELOG.md +951 -0
- package/README.md +89 -21
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +23 -7
- package/package.json +7 -3
- package/schemas/film.schema.json +235 -4
- package/skills/video-studio/SKILL.md +299 -27
- package/skills/video-studio/assets/film.json +7 -14
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +115 -10
- package/skills/video-studio/assets/web/engine.js +81 -3
- package/skills/video-studio/assets/web/film.js +249 -101
- package/skills/video-studio/assets/web/index.html +9 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +159 -0
- package/skills/video-studio/references/audio.md +67 -5
- package/skills/video-studio/references/change-impact.md +2 -2
- 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 +16 -1
- package/skills/video-studio/scripts/filmcheck.py +202 -12
- package/skills/video-studio/scripts/filmkit.py +792 -56
- package/skills/video-studio/scripts/gates.py +161 -14
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +154 -23
- 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 +1134 -78
- package/skills/video-studio/scripts/scorekit.py +305 -76
- package/skills/video-studio/scripts/selfcheck.py +576 -2
- package/skills/video-studio/scripts/sfxkit.py +262 -44
- package/skills/video-studio/scripts/stagewatch.py +159 -36
- package/skills/video-studio/scripts/stylekit.py +137 -18
- package/skills/video-studio/scripts/voicekit.py +23 -7
- package/skills/video-studio/scripts/webengine.py +1186 -85
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +310 -59
- package/tools/estimate.py +251 -61
- package/tools/golden_frames.py +664 -0
- 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 =
|
|
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
|
-
|
|
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=
|
|
950
|
-
except TypeError:
|
|
951
|
-
#
|
|
952
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1803
|
+
if _words_ok(m):
|
|
1804
|
+
return m
|
|
1805
|
+
audio_cached = m # 有音频、缺词级:断网时降级复用它
|
|
1301
1806
|
if not m.get("trimmed") and not trim:
|
|
1302
|
-
|
|
1807
|
+
if _words_ok(m):
|
|
1808
|
+
return m
|
|
1809
|
+
audio_cached = m
|
|
1303
1810
|
# v1 老缓存(升级前合成的)只有在参数完全等于 v1 语义时才复用,
|
|
1304
1811
|
# 避免"改了一个 volume 却拿到老音频"这种升级期的静默错配。
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 != %
|
|
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
|
|
2857
|
+
if nb is None:
|
|
2858
|
+
warns.append("长黑帧检测未执行(ffmpeg blackdetect 失败),本项不能当作通过")
|
|
2859
|
+
elif nb > 0:
|
|
2127
2860
|
warns.append("首尾之外检测到 %d 段长黑帧" % nb)
|
|
2128
|
-
|
|
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
|
# 主题体检的结论带进报告:这样"深色主题必然贴空窗阈值"这种事实不是靠嘴说
|