@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
|
@@ -33,6 +33,31 @@
|
|
|
33
33
|
27 (v0.5.0)内容指纹:同剧本稳定、改标点不换风格、换题材才换
|
|
34
34
|
28 (v0.5.0)声线计划:4 句样片 >=2 把声音,legacy 仍是单声线
|
|
35
35
|
29 (v0.5.0)字体族与主题字幕:换族换缓存键,浅色主题不画黑背板
|
|
36
|
+
30 (v0.6.0)门禁账本与签核:pass/fail/skipped 三档不许混,签核必须绑内容指纹
|
|
37
|
+
31 (v0.6.0)阶段新鲜度:改了输入必须报"过时",不许用旧产物静默出片
|
|
38
|
+
32 (v0.6.0)并行渲染主题序列化:JSON 往返后重解风格必须回到 tuple
|
|
39
|
+
33 (v0.7.0)音效跨片可区分:内容派生 seed 让跨片不同、legacy 逐样本不变
|
|
40
|
+
34 (v0.7.0)配乐/音效片内分离:duck 默认加深,配乐给音效让位
|
|
41
|
+
35 (v0.7.1)旁白优先:人声出现时背景必须再压、间隙恢复、人声电平不动
|
|
42
|
+
36 (v0.7.1)旁白优先默认值:配乐 -12 dB 基线 + 旁白侧链 6 dB(读 st_audio 源码)
|
|
43
|
+
37 (v0.7.1)旁白清晰度 SNR 兜底:背景天然太响时自动补压到 >=12 dB
|
|
44
|
+
38 (v0.7.1)无旁白时混音不受影响(voice_env/guard 不得空转改电平)
|
|
45
|
+
39 (v0.7.2)音频边界:极短旁白不崩、NaN/Inf 拒绝写盘、负起点与 seg=0 有守卫
|
|
46
|
+
40 (v0.7.2)QC 报告落点只有一个来源(--out 的目录,不许 accept 另算一套)
|
|
47
|
+
41 (v0.7.2)空门禁账本不许写成"通过"(没记录 != 通过)
|
|
48
|
+
42 (v0.7.2)单阶段产物归属:不许把别的 stage 写的文件算成自己的(假 stale)
|
|
49
|
+
43 (v0.7.2)theme_overrides 必须参与主题体检(否则能造出"看不清"的成片)
|
|
50
|
+
44 (v0.8.0)词级时间戳:规整(单调/裁剪/偏移)与 cue 词锚点解析
|
|
51
|
+
45 (v0.8.0)画幅规划:只允许 16:9 横屏下采样,竖屏/上采样/非法 fps 必须拒绝
|
|
52
|
+
46 (v0.8.0)金帧确定性:同一帧号两次渲染必须逐像素一致
|
|
53
|
+
47 (v0.8.0)依赖漂移体检:numpy/Pillow 主版本 + edge-tts 仍暴露 boundary 参数
|
|
54
|
+
48 (v0.8.0)批量清单解析:两种写法都认、上限卡死、坏条目在开跑前报错
|
|
55
|
+
49 (v0.8.0)TTS 后端可替换:注册/注销/未知后端的接口契约
|
|
56
|
+
50 (v0.8.0)web 风格载荷:style=None 时不得注入 __STYLE__(legacy 必须回落)
|
|
57
|
+
51 (v0.8.0)金帧回归:与 assets/golden_frames.json 的像素基线比对
|
|
58
|
+
|
|
59
|
+
说明:本文件里 CASES 的**实际条数**是唯一真理,上面的编号表要与它同步。
|
|
60
|
+
v0.8.0 之前这张表停在 43 条而 CASES 已经有 51 条,属于"自检清单自己没被自检"。
|
|
36
61
|
"""
|
|
37
62
|
import argparse
|
|
38
63
|
import io
|
|
@@ -68,7 +93,9 @@ def _setup_stdio():
|
|
|
68
93
|
def run(name, fn):
|
|
69
94
|
try:
|
|
70
95
|
detail = fn()
|
|
71
|
-
|
|
96
|
+
# 前缀判定:SKIP 的理由要能写给人看("skip(缺 xxx.py)"),但 "skipped != 通过"
|
|
97
|
+
# 这条语义不能被理由文字冲掉——旧实现只认精确 "skip",带理由的降级会被记成 PASS。
|
|
98
|
+
if str(detail or "").strip().lower().startswith("skip"):
|
|
72
99
|
RESULTS.append(("SKIP", name, "-"))
|
|
73
100
|
print(" SKIP %s" % name)
|
|
74
101
|
else:
|
|
@@ -430,7 +457,11 @@ def t_music_plan():
|
|
|
430
457
|
file_peak = float(np.max(np.abs(K.wav_read(file_out)[0])))
|
|
431
458
|
assert "music.mode=off" in buf.getvalue(), "off 没有打印说明(静默跳过也不行)"
|
|
432
459
|
assert off_peak < 0.01, "music.mode=off 仍把旧 music.wav 混进来了(峰值 %.3f)" % off_peak
|
|
433
|
-
|
|
460
|
+
# v0.7.1:断言改成"相对 off 明显变大"而不是绝对 0.3——
|
|
461
|
+
# 配乐基线降到 -12 dB(旁白优先)后,0.8 幅度的外部配乐混出来约 0.2;
|
|
462
|
+
# 绝对阈值会把"旁白优先调好了"判成"没读到外部配乐",这类断言会被调参牵着走。
|
|
463
|
+
assert file_peak > 0.1 and file_peak > off_peak * 3.0, (
|
|
464
|
+
"music.mode=file 没有用外部配乐(off %.3f -> file %.3f)" % (off_peak, file_peak))
|
|
434
465
|
return "off 峰值 %.3f / file 峰值 %.3f" % (off_peak, file_peak)
|
|
435
466
|
|
|
436
467
|
|
|
@@ -852,6 +883,530 @@ def _load_scenes_mod():
|
|
|
852
883
|
except Exception: # noqa: BLE001
|
|
853
884
|
return None
|
|
854
885
|
|
|
886
|
+
def t_sfx_content_seed():
|
|
887
|
+
"""v0.7:音效 seed 必须随内容变化,但 legacy 路径必须逐样本不变。
|
|
888
|
+
|
|
889
|
+
- 不传 style/content_fp -> 两次渲染逐样本相同(v0.4 兼容,不破老片);
|
|
890
|
+
- 传了不同 content_fp -> 必须不同(否则跨片音效还是"都一样");
|
|
891
|
+
- 同一 content_fp -> 逐样本相同(可复现,是程序化视频的底线)。
|
|
892
|
+
"""
|
|
893
|
+
import numpy as np
|
|
894
|
+
scenes = [{"id": "l01", "kind": "hook", "start": 0.0, "end": 2.0},
|
|
895
|
+
{"id": "l02", "kind": "body", "start": 2.0, "end": 6.0}]
|
|
896
|
+
rows = [{"at": "l02", "type": "ui_click"}, {"at": "l02+0.5", "type": "whoosh"}]
|
|
897
|
+
|
|
898
|
+
def build(**kw):
|
|
899
|
+
b = K.CueBus(scenes=scenes, total=6.0)
|
|
900
|
+
b.extend(rows)
|
|
901
|
+
return b.build_audio(total=6.0, verbose=False, **kw)
|
|
902
|
+
|
|
903
|
+
try:
|
|
904
|
+
legacy_a, legacy_b = build(), build()
|
|
905
|
+
except Exception: # noqa: BLE001
|
|
906
|
+
return "skip" # sfxkit 不可用
|
|
907
|
+
assert np.array_equal(legacy_a, legacy_b), "legacy 两次渲染不一致(v0.4 兼容被破坏)"
|
|
908
|
+
a = build(style="tech-deep", content_fp="aaaa1111", key="Dm")
|
|
909
|
+
b = build(style="tech-deep", content_fp="bbbb2222", key="Dm")
|
|
910
|
+
assert not np.array_equal(a, b), "不同内容指纹得到相同音效轨:跨片音效仍一模一样"
|
|
911
|
+
a2 = build(style="tech-deep", content_fp="aaaa1111", key="Dm")
|
|
912
|
+
assert np.array_equal(a, a2), "同内容指纹渲染不可复现"
|
|
913
|
+
return "legacy 逐样本相同 / 跨片可区分"
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def t_audio_separation():
|
|
917
|
+
"""v0.7:混音侧 duck 默认必须加深(片内配乐给音效让位)。
|
|
918
|
+
|
|
919
|
+
直接读 render.py 的常量文本而不是跑全链:这条断言盯的是"默认值没被改回去"
|
|
920
|
+
(v0.4 的 3.0 dB 在段落过门时配乐几乎和音效等高,是"听起来像同一套"的成因之一)。
|
|
921
|
+
"""
|
|
922
|
+
import re
|
|
923
|
+
path = os.path.join(HERE, "render.py")
|
|
924
|
+
src = open(path, encoding="utf-8").read()
|
|
925
|
+
m = re.search(r"duck_default\s*=\s*([0-9.]+)", src)
|
|
926
|
+
assert m, "render.py 里找不到 duck_default"
|
|
927
|
+
base = float(m.group(1))
|
|
928
|
+
assert base >= 3.5, "duck_default=%.1f dB 太浅(v0.7 要求 >= 3.5)" % base
|
|
929
|
+
for sid, want in (("mono", 3.0), ("cinema", 5.5), ("epic", 5.5)):
|
|
930
|
+
mm = re.search(r'"%s"\s*:\s*([0-9.]+)' % sid, src)
|
|
931
|
+
assert mm, "render.py 里找不到风格 %s 的 duck 值" % sid
|
|
932
|
+
assert float(mm.group(1)) >= want, "%s duck=%.1f < %.1f" % (sid, float(mm.group(1)), want)
|
|
933
|
+
return "duck 默认 %.1f dB(cinema/epic 5.5)" % base
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def t_voice_priority():
|
|
937
|
+
"""v0.7.1 旁白优先:人声出现时背景必须再压,人声间隙背景恢复。
|
|
938
|
+
|
|
939
|
+
以前配乐基线只比旁白低 1.5 dB,视频以旁白为主时观众要费力分辨字。
|
|
940
|
+
修法:`mix_film` 用旁白包络做侧链——人声处配乐 -6 dB、音效 -3 dB,间隙回原电平。
|
|
941
|
+
这里分别验证「人声段被压」和「静音段恢复」,以及旁白自身电平不被影响。
|
|
942
|
+
"""
|
|
943
|
+
import numpy as np
|
|
944
|
+
n = int(2.0 * K.SR)
|
|
945
|
+
t = np.arange(n) / float(K.SR)
|
|
946
|
+
voice = np.zeros(n, dtype=np.float32)
|
|
947
|
+
voice[: n // 2] = (0.2 * np.sin(2 * np.pi * 220 * t[: n // 2])).astype(np.float32)
|
|
948
|
+
music = np.full((n, 2), 0.2, dtype=np.float32)
|
|
949
|
+
out = K.mix_film(voice, music, None, total=2.0, fade=0.0,
|
|
950
|
+
music_gain_db=0.0, voice_duck_db=6.0, guard_snr_db=0.0)
|
|
951
|
+
v = np.stack([voice, voice], axis=1)
|
|
952
|
+
music_part = out - v # 混音是线性叠加,减掉人声即背景分量
|
|
953
|
+
# 人声段:取中段(避开包络起音/释放的斜坡)
|
|
954
|
+
talk = music_part[int(0.35 * K.SR): int(0.45 * K.SR)]
|
|
955
|
+
gap = music_part[int(1.55 * K.SR): int(1.95 * K.SR)]
|
|
956
|
+
talk_rms = float(np.sqrt(np.mean(talk * talk)))
|
|
957
|
+
gap_rms = float(np.sqrt(np.mean(gap * gap)))
|
|
958
|
+
# 期望:人声段 ≈ 0.2*10^(-6/20)=0.100;间隙 ≈ 0.2
|
|
959
|
+
assert abs(talk_rms - 0.1003) < 0.03, "人声段背景没被压到 -6 dB(RMS %.3f,期望 0.100)" % talk_rms
|
|
960
|
+
assert abs(gap_rms - 0.2) < 0.03, "人声间隙背景没恢复(RMS %.3f,期望 0.200)" % gap_rms
|
|
961
|
+
# SNR 口径:只统计人声活跃区(含间隙会把问题稀释掉)。
|
|
962
|
+
# 这里配乐基线是 0 dB,只有 -6 dB 侧链 -> SNR 应为 -3+6 = 3 dB 左右。
|
|
963
|
+
snr = K.voice_snr(voice, music_part)
|
|
964
|
+
assert snr is not None and 2.0 <= snr <= 4.0, "voice_snr 口径异常(%.1f dB,期望约 3)" % (snr or -99)
|
|
965
|
+
# 旁白自身电平不能被侧链动过
|
|
966
|
+
voice_part = out[:, 0] - music_part[:, 0]
|
|
967
|
+
seg = voice_part[int(0.35 * K.SR): int(0.45 * K.SR)]
|
|
968
|
+
want = voice[int(0.35 * K.SR): int(0.45 * K.SR)]
|
|
969
|
+
assert float(np.max(np.abs(seg - want))) < 1e-4, "旁白电平被侧链改变(旁白优先不能压人声)"
|
|
970
|
+
return "人声段背景 %.3f / 间隙 %.3f(dB 差 %.1f)" % (
|
|
971
|
+
talk_rms, gap_rms, 20 * np.log10(gap_rms / (talk_rms + 1e-9)))
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def t_music_gain_default():
|
|
975
|
+
"""v0.7.1:配乐默认增益必须是 -12 dB(beside 人声),不是旧版的 -1.5。
|
|
976
|
+
|
|
977
|
+
这条盯的是 `render.st_audio` 的默认值:`mus.get("gain_db", -12.0)`。
|
|
978
|
+
直接读源码文本,避免"跑全链才知道默认值"。
|
|
979
|
+
"""
|
|
980
|
+
src = open(os.path.join(HERE, "render.py"), encoding="utf-8").read()
|
|
981
|
+
assert 'gain_db", -12.0' in src, "st_audio 的配乐默认增益不是 -12 dB"
|
|
982
|
+
assert 'voice_duck_db", 6.0' in src, "st_audio 没有开旁白侧链(voice_duck_db=6)"
|
|
983
|
+
return "配乐 -12 dB + 旁白侧链 6 dB"
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def t_snr_guard():
|
|
987
|
+
"""v0.7.1 SNR 兜底:背景天然太响时,混音后必须自己把它压到"人声听得清"。
|
|
988
|
+
|
|
989
|
+
构造一段人声 + 一段刻意很响的配乐(比人声高 10 dB),不设侧链、只靠 guard:
|
|
990
|
+
`mix_film(guard_snr_db=12)` 后实测人声活跃区的 人声/背景 必须 >= 12 dB,
|
|
991
|
+
且人声本身电平不能被兜底改过(兜底只压背景)。
|
|
992
|
+
"""
|
|
993
|
+
import numpy as np
|
|
994
|
+
sr = K.SR
|
|
995
|
+
n = int(3.0 * sr)
|
|
996
|
+
t = np.arange(n) / float(sr)
|
|
997
|
+
voice = np.zeros(n, dtype=np.float32)
|
|
998
|
+
voice[int(0.4*sr):int(2.6*sr)] = (0.2 * np.sin(2*np.pi*220*t[int(0.4*sr):int(2.6*sr)])).astype(np.float32)
|
|
999
|
+
# 配乐 RMS 约 0.6,比人声(RMS 0.141)高 ~12.5 dB —— 刻意做成"盖住旁白"的反例
|
|
1000
|
+
loud = np.full((n, 2), 0.6, dtype=np.float32)
|
|
1001
|
+
out = K.mix_film(voice, loud, None, total=3.0, fade=0.0,
|
|
1002
|
+
music_gain_db=0.0, voice_duck_db=0.0, voice_duck_sfx_db=0.0,
|
|
1003
|
+
guard_snr_db=12.0, guard_cap_db=30.0, verbose=False)
|
|
1004
|
+
v2 = np.stack([voice, voice], axis=1)
|
|
1005
|
+
bg = out - v2
|
|
1006
|
+
snr = K.voice_snr(voice, bg)
|
|
1007
|
+
assert snr is not None and snr >= 11.9, "SNR 兜底没生效:人声/背景 %.1f dB(要求 >=12)" % (snr or -99)
|
|
1008
|
+
# 人声电平不能被兜底动过
|
|
1009
|
+
seg_v = (out[:, 0] - bg[:, 0])[int(1.0*sr):int(2.0*sr)]
|
|
1010
|
+
want = voice[int(1.0*sr):int(2.0*sr)]
|
|
1011
|
+
assert float(np.max(np.abs(seg_v - want))) < 1e-4, "SNR 兜底改到了人声电平"
|
|
1012
|
+
return "兜底前约 -12.5 dB -> 兜底后 %.1f dB" % snr
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def t_mix_no_voice():
|
|
1016
|
+
"""没有旁白时混音行为不变:没有 voice_env/guard 可用,背景应原样输出。"""
|
|
1017
|
+
import numpy as np
|
|
1018
|
+
n = int(0.5 * K.SR)
|
|
1019
|
+
music = np.full((n, 2), 0.2, dtype=np.float32)
|
|
1020
|
+
out = K.mix_film(None, music, None, total=0.5, fade=0.0, music_gain_db=0.0, verbose=False)
|
|
1021
|
+
assert abs(float(np.max(np.abs(out))) - 0.2) < 1e-5, "无旁白时配乐被改动了(峰值 %.3f)" % float(np.max(np.abs(out)))
|
|
1022
|
+
return "无旁白时背景不被压"
|
|
1023
|
+
|
|
1024
|
+
# ------------------------------------------------------------------ v0.7.2 新增(全项目深审)
|
|
1025
|
+
def t_audio_edge_inputs():
|
|
1026
|
+
"""39 音频入口的边界:极短旁白不能崩、NaN/Inf 必须拒绝、seg=0 不许死循环。
|
|
1027
|
+
|
|
1028
|
+
这几条都是"真跑一次就会遇到"的输入。v0.7.1 及以前的行为:
|
|
1029
|
+
* voice_env 在极短音频上 np.convolve(mode="same") 返回核长 -> np.maximum 广播失败;
|
|
1030
|
+
* duck_voice 在"旁白比背景短"时包络与背景长度不同 -> 广播失败;
|
|
1031
|
+
* wav_write 对 NaN 做 clip 再 astype(int16),把 NaN 静默写成 0(写出损坏 WAV 却不报错);
|
|
1032
|
+
* AudioBus.place 传负起点时负下标切片 -> n=0 -> 广播崩;
|
|
1033
|
+
* music_bed 的 seg=0 让 t 不前进 -> while 死循环(有 100000 次上限,6 秒片要 2 分钟)。
|
|
1034
|
+
"""
|
|
1035
|
+
import numpy as np
|
|
1036
|
+
short = np.full(600, 0.2, dtype=np.float32) # 12.5 ms
|
|
1037
|
+
assert len(K.voice_env(short)) >= 1, "voice_env 对极短音频返回空包络"
|
|
1038
|
+
bg = np.full((K.SR, 2), 0.2, dtype=np.float32)
|
|
1039
|
+
got = K.duck_voice(bg, short, depth_db=6.0)
|
|
1040
|
+
assert got.shape == (K.SR, 2), "duck_voice 在旁白比背景短时形状不对: %r" % (got.shape,)
|
|
1041
|
+
m = np.full((600, 2), 0.2, dtype=np.float32)
|
|
1042
|
+
assert K.duck(m, short).shape == (600, 2), "duck 在短信号上形状不对"
|
|
1043
|
+
|
|
1044
|
+
with tempfile.TemporaryDirectory() as d:
|
|
1045
|
+
for name, bad in (("nan", np.float32("nan")), ("inf", np.float32("inf"))):
|
|
1046
|
+
p = os.path.join(d, name + ".wav")
|
|
1047
|
+
try:
|
|
1048
|
+
K.wav_write(p, np.array([0.1, bad], dtype=np.float32))
|
|
1049
|
+
except ValueError:
|
|
1050
|
+
assert not os.path.exists(p), "wav_write 报错了却仍写出文件(%s)" % p
|
|
1051
|
+
else:
|
|
1052
|
+
raise AssertionError("wav_write 把 %s 静默写进了 WAV(应当报错)" % name)
|
|
1053
|
+
|
|
1054
|
+
bus = K.AudioBus(2, K.SR, 1.0)
|
|
1055
|
+
bus.place(np.ones(1000, dtype=np.float32), -0.25) # 负起点:从 0 秒开始,不许崩
|
|
1056
|
+
bus2 = K.AudioBus(2, K.SR, 0.3)
|
|
1057
|
+
try:
|
|
1058
|
+
bus2.music_bed([[220.0, 277.0, 330.0]], seg=0.0)
|
|
1059
|
+
except ValueError:
|
|
1060
|
+
pass
|
|
1061
|
+
else:
|
|
1062
|
+
raise AssertionError("music_bed(seg=0) 没被拒绝(会死循环)")
|
|
1063
|
+
return "极短旁白不崩 / NaN·Inf 拒绝 / 负起点与 seg=0 有守卫"
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def t_report_single_source():
|
|
1067
|
+
"""40 QC 报告落点只能有一个来源:--out 的目录。
|
|
1068
|
+
|
|
1069
|
+
v0.7.2 修的真 bug:all/mux/qa 用 dirname(abspath(out)),accept 用
|
|
1070
|
+
dirname(abspath(work))/out。--out 指到 deliver/ 时,签核更新的是另一份没人
|
|
1071
|
+
交付的文件,交付出去的那份仍是"未签核"——正好打穿"报告是交付凭据"这条承诺。
|
|
1072
|
+
"""
|
|
1073
|
+
import types
|
|
1074
|
+
try:
|
|
1075
|
+
import render
|
|
1076
|
+
except Exception as e: # noqa: BLE001
|
|
1077
|
+
return "skip(import render 失败:%s)" % e
|
|
1078
|
+
assert hasattr(render, "_report_path"), "render 没有统一的 _report_path"
|
|
1079
|
+
a = types.SimpleNamespace(out=os.path.join("deliver", "film.mp4"), work="work")
|
|
1080
|
+
got = render._report_path(a)
|
|
1081
|
+
want = os.path.join("deliver", "QC-REPORT.md")
|
|
1082
|
+
assert os.path.abspath(got) == os.path.abspath(want), \
|
|
1083
|
+
"报告没落在 --out 旁边:%s(期望 %s)" % (got, want)
|
|
1084
|
+
src = open(os.path.join(HERE, "render.py"), encoding="utf-8").read()
|
|
1085
|
+
n = src.count('"QC-REPORT.md"')
|
|
1086
|
+
assert n == 1, "QC-REPORT.md 路径出现了 %d 处(应只有 _report_path 一处,否则又会两边漂移)" % n
|
|
1087
|
+
return "报告落点统一到 --out 目录(源码里只有一处拼接)"
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def t_empty_ledger_not_pass():
|
|
1091
|
+
"""41 门禁账本为空时,QC 报告不许说"通过"。
|
|
1092
|
+
|
|
1093
|
+
render_report 原来 if counts.get(FAIL): ... else: **通过**——gates.json 被删、
|
|
1094
|
+
写坏、或根本没跑 qa 时,交付凭据会是一份宣称通过的纸。这条盯的就是那个 else。
|
|
1095
|
+
"""
|
|
1096
|
+
try:
|
|
1097
|
+
import gates as GT
|
|
1098
|
+
except Exception as e: # noqa: BLE001
|
|
1099
|
+
return "skip(没有 gates.py:%s)" % e
|
|
1100
|
+
with tempfile.TemporaryDirectory() as d:
|
|
1101
|
+
p = GT.write_report(d, os.path.join(d, "QC-REPORT.md"))
|
|
1102
|
+
txt = open(p, encoding="utf-8").read()
|
|
1103
|
+
assert "**通过**" not in txt, "空门禁账本却写成『通过』(交付凭据撒谎)"
|
|
1104
|
+
assert "未判定" in txt, "空门禁账本没写『未判定』(用户会以为跑过了)"
|
|
1105
|
+
return "0 条门禁 -> 未判定(不是通过)"
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def t_stage_io_no_phantom_output():
|
|
1109
|
+
"""42 单阶段连跑不许报"自己的产物不见了"(假 stale)。
|
|
1110
|
+
|
|
1111
|
+
v0.7.2 修:stagewatch 的 STAGE_IO[style].outputs 里把 voice_plan.json 也算成
|
|
1112
|
+
style 的产物,而它其实是 tts 阶段写的(render.py 的 st_tts)。于是 --stage style
|
|
1113
|
+
连跑两次,第二次必然报"产物 voice_plan.json 不见了";打开 --strict-stale 就变成
|
|
1114
|
+
硬失败——SKILL.md 第 3 步(全新项目第一条生产命令)自己走不通。
|
|
1115
|
+
"""
|
|
1116
|
+
try:
|
|
1117
|
+
import stagewatch as SW
|
|
1118
|
+
except Exception as e: # noqa: BLE001
|
|
1119
|
+
return "skip(没有 stagewatch.py:%s)" % e
|
|
1120
|
+
style_out = list((SW.STAGE_IO.get("style") or {}).get("outputs") or [])
|
|
1121
|
+
tts_out = list((SW.STAGE_IO.get("tts") or {}).get("outputs") or [])
|
|
1122
|
+
if "voice_plan.json" in style_out:
|
|
1123
|
+
assert "voice_plan.json" in tts_out, \
|
|
1124
|
+
"voice_plan.json 被算成 style 的产物,但没有别的 stage 认领它(假 stale 会回来)"
|
|
1125
|
+
dup = [(n, st) for st, io in SW.STAGE_IO.items() for n in (io.get("outputs") or [])
|
|
1126
|
+
if n in style_out and st != "style"]
|
|
1127
|
+
assert not dup, \
|
|
1128
|
+
"style 的产物被别的 stage 也声明成输出(会产生假 stale):%r" % (dup,)
|
|
1129
|
+
return "style 产物 %s / tts 产物 %s" % (",".join(sorted(style_out)), ",".join(sorted(tts_out)))
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def t_theme_overrides_linted():
|
|
1133
|
+
"""43 theme_overrides 必须参与主题体检(否则用户能造出"看不清"的成片)。
|
|
1134
|
+
|
|
1135
|
+
v0.7.2 修的真 bug:render.resolve_style 以前只体检**基础主题**
|
|
1136
|
+
(ST.THEMES[theme_id]),而 scenes 实际用的是 plan[theme](基础主题 + film.json 的
|
|
1137
|
+
visual.theme_overrides)。于是把 ink/bg 调成低对比度时体检照样 PASS,渲完才发现字幕看不清。
|
|
1138
|
+
实测 overrides ink=[30,30,30]/bg=[10,10,10]:基础主题 lint 为空,最终主题 lint 报对比度 1.09。
|
|
1139
|
+
"""
|
|
1140
|
+
import stylekit as ST
|
|
1141
|
+
base = {'title': 'x', 'style': 'auto',
|
|
1142
|
+
'lines': [{'id': 'l01', 'kind': 'body', 'text': 'y'}]}
|
|
1143
|
+
p = ST.resolve_script_style(dict(base, visual={'theme_overrides': {
|
|
1144
|
+
'ink': [30, 30, 30], 'bg': [10, 10, 10]}}))
|
|
1145
|
+
assert ST.theme_lint(ST.THEMES[p['theme_id']]) == [], \
|
|
1146
|
+
"前提变了:基础主题本身就有 lint 问题,这条断言失去意义"
|
|
1147
|
+
bad = ST.theme_lint(p['theme'])
|
|
1148
|
+
assert bad, '低对比度的 theme_overrides 没被 lint 抓到(用户能造出看不清的字幕)'
|
|
1149
|
+
src = open(os.path.join(HERE, "render.py"), encoding="utf-8").read()
|
|
1150
|
+
assert "ST.theme_lint(plan.get('theme')" in src or 'ST.theme_lint(plan.get("theme")' in src, \
|
|
1151
|
+
"render.resolve_style 仍在体检基础主题而不是最终主题"
|
|
1152
|
+
return "最终主题参与体检(%s)" % ";".join(bad)[:60]
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
# ------------------------------------------------------------------ v0.8.0 用例
|
|
1156
|
+
def t_word_events():
|
|
1157
|
+
"""44 词级时间戳:规整/裁剪平移/单调兜底/词锚点解析(不联网)。
|
|
1158
|
+
|
|
1159
|
+
为什么能在不联网的 selfcheck 里测:真正的网络合成在 --stage tts 里,
|
|
1160
|
+
而"事件 -> 句内词表"这一步是纯函数。离线固定住它,等于固定住了字幕断行、
|
|
1161
|
+
cue 词锚点、G10 门禁三处的共同数据源。
|
|
1162
|
+
"""
|
|
1163
|
+
# offset 用 100ns 单位(实测 edge-tts 7.3.1 口径):0.10s / 0.36s / 0.50s 起,
|
|
1164
|
+
# 词之间刻意留 0.02s 级空隙 —— 真实事件本来就不连续覆盖,抹平它就等于制造错位。
|
|
1165
|
+
ev = [{"type": "WordBoundary", "offset": 1000000, "duration": 2360000, "text": "别"},
|
|
1166
|
+
{"type": "WordBoundary", "offset": 3600000, "duration": 1180000, "text": "把"},
|
|
1167
|
+
{"type": "WordBoundary", "offset": 5000000, "duration": 5050000, "text": "傻子"}]
|
|
1168
|
+
ws = K.words_normalize(ev, offset_shift=0.1, dur=1.6)
|
|
1169
|
+
assert len(ws) == 3, "词事件数被吃掉:%r" % (ws,)
|
|
1170
|
+
assert abs(ws[0]["start"] - 0.0) < 1e-6, "句首裁剪平移没生效(start=%.4f)" % ws[0]["start"]
|
|
1171
|
+
# 词间空隙必须保留(中文词之间本来就有 0.1–0.3s 停),不能被"拉平"
|
|
1172
|
+
assert ws[1]["start"] > ws[0]["end"], "词间隙被抹平了(会让字幕和语音错位)"
|
|
1173
|
+
assert all(ws[i]["start"] <= ws[i + 1]["start"] for i in range(len(ws) - 1)), "词偏移非单调"
|
|
1174
|
+
# 末词终点不得超过整句实测时长
|
|
1175
|
+
ws2 = K.words_normalize(ev, dur=0.5)
|
|
1176
|
+
assert ws2[-1]["end"] <= 0.5 + 1e-6, "末词被算到句外:%r" % (ws2[-1],)
|
|
1177
|
+
# 空事件 -> 空表(调用方据此判 skipped,而不是当成"有词级")
|
|
1178
|
+
assert K.words_normalize([]) == [], "空事件应得到空表"
|
|
1179
|
+
# 词锚点:l03:w2 = 第二个词的起点
|
|
1180
|
+
scenes = [dict(id="l03", start=2.0, dur=4.0,
|
|
1181
|
+
words_abs=[{"text": "别", "start": 2.55, "end": 2.79},
|
|
1182
|
+
{"text": "把", "start": 2.79, "end": 2.91}])]
|
|
1183
|
+
assert abs(K.cue_time("l03:w2", scenes) - 2.79) < 1e-6, "词锚点 l03:w2 解析错"
|
|
1184
|
+
assert abs(K.cue_time("l03:w2+0.2", scenes) - 2.99) < 1e-6, "词锚点带偏移解析错"
|
|
1185
|
+
assert abs(K.cue_time("l03+0.5", scenes) - 2.5) < 1e-6, "旧写法 l03+0.5 被改坏"
|
|
1186
|
+
return "3 词事件 / 平移 %.2fs / 词锚点 OK"
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def t_video_plan():
|
|
1190
|
+
"""45 画幅规划:只允许 16:9 横屏 + 下采样,竖屏/上采样必须报错。
|
|
1191
|
+
|
|
1192
|
+
这条守的是"用户以为能出竖屏"的期望管理:安全区/版式/HUD 全按 16:9 像素写死,
|
|
1193
|
+
静默出一个变形或带黑边的片子,比直接报错更伤。
|
|
1194
|
+
"""
|
|
1195
|
+
p = K.video_plan({})
|
|
1196
|
+
assert (p["out_w"], p["out_h"], p["fps"]) == (1920, 1080, 30), "默认规格变了:%r" % (p,)
|
|
1197
|
+
assert p["scale"] is False, "默认不该触发缩放"
|
|
1198
|
+
p7 = K.video_plan({"video": {"width": 1280, "height": 720}})
|
|
1199
|
+
assert (p7["out_w"], p7["out_h"]) == (1280, 720) and p7["scale"], "720p 下采样没生效"
|
|
1200
|
+
assert p7["w"] == 1920 and p7["h"] == 1080, "内部构图应恒为 1080p(缩放只在编码时做)"
|
|
1201
|
+
p24 = K.video_plan({"video": {"fps": 24}})
|
|
1202
|
+
assert p24["fps"] == 24, "fps 参数化没生效"
|
|
1203
|
+
|
|
1204
|
+
def _must_fail(cfg, why):
|
|
1205
|
+
try:
|
|
1206
|
+
K.video_plan(cfg)
|
|
1207
|
+
except ValueError:
|
|
1208
|
+
return
|
|
1209
|
+
raise AssertionError("%s 没有报错(会被静默接受)" % why)
|
|
1210
|
+
|
|
1211
|
+
_must_fail({"video": {"width": 1080, "height": 1920}}, "竖屏")
|
|
1212
|
+
_must_fail({"video": {"width": 1920, "height": 1440}}, "4:3")
|
|
1213
|
+
_must_fail({"video": {"width": 3840, "height": 2160}}, "上采样到 4K")
|
|
1214
|
+
_must_fail({"video": {"fps": 31}}, "非法 fps")
|
|
1215
|
+
return "默认 1080p30 / 720p 下采样 / 竖屏与上采样均拒绝"
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def t_golden_determinism():
|
|
1219
|
+
"""46 金帧确定性:同一帧连画两次必须逐字节一致(跨进程可复现的前提)。
|
|
1220
|
+
|
|
1221
|
+
比"看起来一样"强的地方:它会在**改了引擎/主题/随机源**时立刻红,
|
|
1222
|
+
而不是等观众看出"这版画面怎么抖了一下"。
|
|
1223
|
+
"""
|
|
1224
|
+
mod = _load_scenes_mod()
|
|
1225
|
+
if mod is None:
|
|
1226
|
+
return "skip(没有 assets/scenes.py)"
|
|
1227
|
+
import hashlib
|
|
1228
|
+
sc = dict(id="l01", kind="hook", start=0.0, dur=4.0, chapter="01 · 测试",
|
|
1229
|
+
text="金帧确定性自检", sub="金帧确定性", big="金帧确定性")
|
|
1230
|
+
script = {"title": "selfcheck", "_total": 8.0}
|
|
1231
|
+
try:
|
|
1232
|
+
plan = __import__("stylekit").resolve_script_style(dict(script, lines=[sc]))
|
|
1233
|
+
script["_style"] = plan
|
|
1234
|
+
except Exception: # noqa: BLE001
|
|
1235
|
+
pass
|
|
1236
|
+
hs = []
|
|
1237
|
+
for _ in range(2):
|
|
1238
|
+
fr = mod.draw_frame(sc, 1.25, 4.0, 1.25, script, 37)
|
|
1239
|
+
hs.append(hashlib.sha256(fr.tobytes()).hexdigest()[:16])
|
|
1240
|
+
assert hs[0] == hs[1], "同一帧画了两次不一致(有隐藏的 random/time 依赖):%r" % (hs,)
|
|
1241
|
+
return "同一帧两次 SHA-256 = %s" % hs[0]
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def t_dependency_drift():
|
|
1245
|
+
"""47 依赖漂移体检:上游一改就悄悄退化,这里先喊。
|
|
1246
|
+
|
|
1247
|
+
只做**离线**断言(不联网、不下载):主版本区间 + edge-tts 是否仍暴露
|
|
1248
|
+
boundary 参数。词级时间戳整条链路吊在这个参数上,7.x 一旦改名,
|
|
1249
|
+
表现会是"词事件为空",而不是报错。
|
|
1250
|
+
"""
|
|
1251
|
+
import numpy as np
|
|
1252
|
+
from PIL import __version__ as pil_ver
|
|
1253
|
+
assert int(np.__version__.split(".")[0]) >= 1, "numpy 主版本异常:%s" % np.__version__
|
|
1254
|
+
assert int(pil_ver.split(".")[0]) >= 9, "Pillow 版本过旧:%s" % pil_ver
|
|
1255
|
+
try:
|
|
1256
|
+
import edge_tts
|
|
1257
|
+
except Exception: # noqa: BLE001
|
|
1258
|
+
return "skip(没装 edge-tts,只能用手工音频)"
|
|
1259
|
+
try:
|
|
1260
|
+
mode = edge_tts.Communicate.__init__.__doc__ or ""
|
|
1261
|
+
import inspect
|
|
1262
|
+
params = inspect.signature(edge_tts.Communicate.__init__).parameters
|
|
1263
|
+
except (TypeError, ValueError): # noqa: BLE001
|
|
1264
|
+
return "skip(edge-tts 的签名取不到,版本 %s)" % getattr(edge_tts, "__version__", "?")
|
|
1265
|
+
assert "boundary" in params, ("edge-tts %s 的 Communicate 不再接受 boundary 参数:词级时间戳链路会静默退化成"
|
|
1266
|
+
"整句字幕,请检查上游变更" % getattr(edge_tts, "__version__", "?"))
|
|
1267
|
+
return "numpy %s / Pillow %s / edge-tts %s(boundary OK)" % (
|
|
1268
|
+
np.__version__, pil_ver, getattr(edge_tts, "__version__", "?"))
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
# ------------------------------------------------------------------ v0.8.0 用例(续)
|
|
1272
|
+
def t_batch_manifest():
|
|
1273
|
+
"""48 批量清单解析:两种写法都认、上限卡死、坏条目在**开跑前**报错。
|
|
1274
|
+
|
|
1275
|
+
批量最容易死在"第 7 条跑一半才发现清单写错"。这条固定住的是
|
|
1276
|
+
render._load_batch 的"先解析、后执行"契约——写错就一条都不跑。
|
|
1277
|
+
"""
|
|
1278
|
+
import render as R
|
|
1279
|
+
|
|
1280
|
+
def _must_fail(payload, why):
|
|
1281
|
+
with tempfile.TemporaryDirectory() as d:
|
|
1282
|
+
p = os.path.join(d, "b.json")
|
|
1283
|
+
open(p, "w", encoding="utf-8").write(payload)
|
|
1284
|
+
try:
|
|
1285
|
+
R._load_batch(p)
|
|
1286
|
+
except SystemExit:
|
|
1287
|
+
return
|
|
1288
|
+
raise AssertionError("%s 没有被拒绝(会带着坏清单开跑)" % why)
|
|
1289
|
+
|
|
1290
|
+
with tempfile.TemporaryDirectory() as d:
|
|
1291
|
+
film = os.path.join(d, "a", "film.json")
|
|
1292
|
+
os.makedirs(os.path.dirname(film), exist_ok=True)
|
|
1293
|
+
open(film, "w", encoding="utf-8").write("{}")
|
|
1294
|
+
p = os.path.join(d, "list.json")
|
|
1295
|
+
json.dump(["a/film.json"], open(p, "w", encoding="utf-8"))
|
|
1296
|
+
jobs = R._load_batch(p)
|
|
1297
|
+
assert len(jobs) == 1, "简写清单解析错:%r" % (jobs,)
|
|
1298
|
+
assert os.path.isfile(jobs[0]["script"]), "剧本路径没转成绝对路径:%r" % (jobs[0],)
|
|
1299
|
+
assert jobs[0]["work"].endswith("work_film"), "默认 work 目录命名变了:%r" % (jobs[0],)
|
|
1300
|
+
p2 = os.path.join(d, "list2.json")
|
|
1301
|
+
json.dump({"films": [{"script": "a/film.json", "label": "x",
|
|
1302
|
+
"work": "w1", "out": "o1.mp4"}]},
|
|
1303
|
+
open(p2, "w", encoding="utf-8"))
|
|
1304
|
+
j2 = R._load_batch(p2)
|
|
1305
|
+
assert j2[0]["label"] == "x" and j2[0]["out"].endswith("o1.mp4"), "显式字段被忽略:%r" % (j2,)
|
|
1306
|
+
_must_fail("[]", "空清单")
|
|
1307
|
+
_must_fail('{"films": [{"label": "x"}]}', "缺 script 的条目")
|
|
1308
|
+
_must_fail("[" * 0 + json.dumps([{"script": "a.json"}] * 101), "超过 100 条上限")
|
|
1309
|
+
return "两种写法 + 3 类坏清单都按预期拒绝"
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def t_tts_backend():
|
|
1313
|
+
"""49 TTS 后端可替换:注册即生效、未知后端报错、默认仍是 edge。
|
|
1314
|
+
|
|
1315
|
+
这条守的是"旁白链路只挂在非官方 edge-tts 上"的对冲面:换 provider 时
|
|
1316
|
+
timeline/layout/QA 不该跟着改。这里只测接口契约,不联网。
|
|
1317
|
+
"""
|
|
1318
|
+
names = K.tts_backend_names()
|
|
1319
|
+
assert names[0] == "edge", "默认后端不是 edge:%r" % (names,)
|
|
1320
|
+
|
|
1321
|
+
def fake(text, out_mp3, **kw):
|
|
1322
|
+
with open(out_mp3, "wb") as fh:
|
|
1323
|
+
fh.write(b"\x00" * 4096)
|
|
1324
|
+
return dict(path=out_mp3, dur=1.25, words=[], trimmed=False)
|
|
1325
|
+
|
|
1326
|
+
import tempfile as _tf
|
|
1327
|
+
with _tf.TemporaryDirectory() as d:
|
|
1328
|
+
mp3 = os.path.join(d, "x.mp3")
|
|
1329
|
+
try:
|
|
1330
|
+
K.tts("测试", mp3, backend="no-such-backend")
|
|
1331
|
+
except RuntimeError as e:
|
|
1332
|
+
assert "未知的 TTS 后端" in str(e), "未知后端报错文案不清晰:%s" % e
|
|
1333
|
+
else:
|
|
1334
|
+
raise AssertionError("未知后端没有报错(会静默走到别的路径)")
|
|
1335
|
+
K.register_tts_backend("fake-test", fake, note="selfcheck")
|
|
1336
|
+
assert "fake-test" in K.tts_backend_names()
|
|
1337
|
+
m = K.tts("测试", mp3, backend="fake-test")
|
|
1338
|
+
assert m["dur"] == 1.25 and m["backend"] == "fake-test", "后端返回值没被采纳:%r" % (m,)
|
|
1339
|
+
return "注册/分发/未知后端报错 全部符合契约"
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def t_web_style_payload():
|
|
1343
|
+
"""50 web 引擎风格载荷:非 legacy 才注入、legacy 必须回落 None(逐字节兼容的前提)。
|
|
1344
|
+
|
|
1345
|
+
v0.8 给 web 引擎加了"跟随风格包",但兼容性要求是 style=None 时页面输出**逐字节**
|
|
1346
|
+
等于 v0.7。这条守住"什么时候该注入、什么时候必须不注入"这个开关。
|
|
1347
|
+
"""
|
|
1348
|
+
import render as R
|
|
1349
|
+
try:
|
|
1350
|
+
import webengine as we
|
|
1351
|
+
except Exception as e: # noqa: BLE001
|
|
1352
|
+
return "skip(没有 webengine.py:%s)" % e
|
|
1353
|
+
script = {"title": "x", "style": "cinema",
|
|
1354
|
+
"lines": [{"id": "l01", "kind": "hook", "text": "y"}]}
|
|
1355
|
+
tl = {"fps": 30, "video": {"out_w": 1280, "out_h": 720}}
|
|
1356
|
+
payload = R._web_style_payload(we, script, tl)
|
|
1357
|
+
assert payload is not None, "非 legacy 项目没有生成 style 载荷(页面不会跟随风格)"
|
|
1358
|
+
canvas = payload.get("canvas") or {}
|
|
1359
|
+
assert int(canvas.get("w") or 0) == 1280 and int(canvas.get("h") or 0) == 720, \
|
|
1360
|
+
"画布尺寸没跟着 video 块走:%r" % (canvas,)
|
|
1361
|
+
legacy = {"title": "x", "lines": [{"id": "l01", "kind": "hook", "text": "y"}]}
|
|
1362
|
+
assert R._web_style_payload(we, legacy, tl) is None, \
|
|
1363
|
+
"legacy 项目也注入了 style(v0.7 的逐字节兼容会被打破)"
|
|
1364
|
+
return "非 legacy 注入 %dx%d / legacy 回落 None" % (canvas["w"], canvas["h"])
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def t_golden_frames():
|
|
1368
|
+
"""51 金帧回归:像素基线真的能拦住"画面变了"(仓库工具 tools/golden_frames.py)。
|
|
1369
|
+
|
|
1370
|
+
为什么放进 selfcheck 而不是只放 CI:改引擎/主题后"画面变了一点"是最难发现的一类回归,
|
|
1371
|
+
而它只需要 0.65 秒。约定:基线缺失 / 字体或环境指纹不同 -> skip(跨机不误报),
|
|
1372
|
+
同环境像素变了才是 fail。
|
|
1373
|
+
"""
|
|
1374
|
+
# HERE = <repo>/skills/video-studio/scripts -> 上溯三级才是仓库根
|
|
1375
|
+
# (两级会落到 <repo>/skills,工具与基线都找不到,这条就永远 skip —— 静默失效)
|
|
1376
|
+
root = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
|
|
1377
|
+
tool = os.path.join(root, "tools", "golden_frames.py")
|
|
1378
|
+
if not os.path.exists(tool):
|
|
1379
|
+
return "skip(没有 tools/golden_frames.py)"
|
|
1380
|
+
base = os.path.join(root, "skills", "video-studio", "assets", "golden_frames.json")
|
|
1381
|
+
if not os.path.exists(base):
|
|
1382
|
+
return "skip(没有金帧基线 assets/golden_frames.json)"
|
|
1383
|
+
p = subprocess.run([sys.executable, tool, "--check", "--json"],
|
|
1384
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1385
|
+
cwd=root)
|
|
1386
|
+
out = (p.stdout or "").strip()
|
|
1387
|
+
i = out.find("{")
|
|
1388
|
+
if i < 0:
|
|
1389
|
+
return "skip(golden_frames 没输出 JSON:%s)" % (p.stderr or out)[:120]
|
|
1390
|
+
try:
|
|
1391
|
+
d = json.loads(out[i:])
|
|
1392
|
+
except json.JSONDecodeError as e:
|
|
1393
|
+
return "skip(golden_frames JSON 解析失败:%s)" % e
|
|
1394
|
+
changed = d.get("changed") or []
|
|
1395
|
+
status = str(d.get("status") or "")
|
|
1396
|
+
# 三档必须分开判:工具在"基线缺失 / 字体或环境指纹不同"时返回 exit 0 + status=skipped,
|
|
1397
|
+
# 只认 returncode 会把"这次根本没比"记成 PASS。实测踩过:本机 Python 3.12.14、基线记的是
|
|
1398
|
+
# 3.12.16,于是第 51 条显示"0 帧像素一致"进了 PASS 列——而它一帧都没比。
|
|
1399
|
+
if changed:
|
|
1400
|
+
raise AssertionError("金帧变了(画面回归):changed=%r;若是有意改画风,"
|
|
1401
|
+
"确认后用 python tools/golden_frames.py --write 重录基线" % (changed,))
|
|
1402
|
+
if status == "pass" and p.returncode == 0:
|
|
1403
|
+
frames = d.get("frames") or []
|
|
1404
|
+
return "%d 帧像素一致(font_fp %s)" % (len(frames),
|
|
1405
|
+
str(d.get("font_fp") or "")[:8])
|
|
1406
|
+
return "skip(golden_frames status=%s exit=%s:%s)" % (
|
|
1407
|
+
status or "-", p.returncode, (d.get("reason") or "")[:120])
|
|
1408
|
+
|
|
1409
|
+
|
|
855
1410
|
CASES = [
|
|
856
1411
|
("1 TTS 缓存校验文本", t_cache_key),
|
|
857
1412
|
("2 sub_window 覆盖长句", t_sub_window),
|
|
@@ -885,6 +1440,25 @@ CASES = [
|
|
|
885
1440
|
("30 门禁账本与签核", t_gates),
|
|
886
1441
|
("31 阶段新鲜度守卫", t_stagewatch),
|
|
887
1442
|
("32 并行渲染主题序列化", t_parallel_frames),
|
|
1443
|
+
("33 音效跨片可区分(v0.7 内容派生 seed)", t_sfx_content_seed),
|
|
1444
|
+
("34 配乐/音效片内分离(v0.7 duck 加深)", t_audio_separation),
|
|
1445
|
+
("35 旁白优先(v0.7.1 背景让位与恢复)", t_voice_priority),
|
|
1446
|
+
("36 旁白优先默认值(配乐 -12 dB + 侧链)", t_music_gain_default),
|
|
1447
|
+
("37 旁白清晰度 SNR 兜底(背景太响时自动补压)", t_snr_guard),
|
|
1448
|
+
("38 无旁白时混音不受影响", t_mix_no_voice),
|
|
1449
|
+
("39 音频边界:极短/NaN·Inf/负起点/seg=0", t_audio_edge_inputs),
|
|
1450
|
+
("40 QC 报告落点只有一个来源", t_report_single_source),
|
|
1451
|
+
("41 空门禁账本不许写成通过", t_empty_ledger_not_pass),
|
|
1452
|
+
("42 单阶段产物归属(防假 stale)", t_stage_io_no_phantom_output),
|
|
1453
|
+
("43 theme_overrides 参与主题体检", t_theme_overrides_linted),
|
|
1454
|
+
("44 词级时间戳(规整/裁剪/锚点)", t_word_events),
|
|
1455
|
+
("45 画幅规划(只允许横屏下采样)", t_video_plan),
|
|
1456
|
+
("46 金帧确定性(同帧两次一致)", t_golden_determinism),
|
|
1457
|
+
("47 依赖漂移体检(离线)", t_dependency_drift),
|
|
1458
|
+
("48 批量清单解析(预检先于执行)", t_batch_manifest),
|
|
1459
|
+
("49 TTS 后端可替换(接口契约)", t_tts_backend),
|
|
1460
|
+
("50 web 风格载荷(legacy 必须回落)", t_web_style_payload),
|
|
1461
|
+
("51 金帧回归(像素基线)", t_golden_frames),
|
|
888
1462
|
]
|
|
889
1463
|
|
|
890
1464
|
|