@dsh-gis/dsh-video-studio-skill 0.7.2 → 0.9.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 +1109 -0
- package/README.md +76 -10
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-before-after.png +0 -0
- package/index.js +4 -1
- package/package.json +7 -3
- package/schemas/film.schema.json +329 -2
- package/skills/video-studio/SKILL.md +270 -13
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +2162 -1190
- package/skills/video-studio/assets/web/engine.js +63 -2
- package/skills/video-studio/assets/web/film.js +224 -101
- package/skills/video-studio/assets/web/index.html +4 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +4 -3
- package/skills/video-studio/references/audio.md +2 -2
- package/skills/video-studio/references/change-impact.md +1 -1
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/scene-grammar.md +306 -0
- package/skills/video-studio/references/style-packs.md +108 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +14 -0
- package/skills/video-studio/scripts/filmcheck.py +218 -0
- package/skills/video-studio/scripts/filmkit.py +521 -38
- package/skills/video-studio/scripts/gates.py +88 -4
- package/skills/video-studio/scripts/grammar.py +559 -0
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +49 -8
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +764 -32
- package/skills/video-studio/scripts/scorekit.py +16 -30
- package/skills/video-studio/scripts/selfcheck.py +483 -0
- package/skills/video-studio/scripts/sfxkit.py +130 -14
- package/skills/video-studio/scripts/stagewatch.py +19 -6
- package/skills/video-studio/scripts/stylekit.py +242 -12
- package/skills/video-studio/scripts/voicekit.py +15 -7
- package/skills/video-studio/scripts/webengine.py +1119 -72
- package/tools/ci_check.py +173 -31
- package/tools/estimate.py +130 -19
- package/tools/golden_frames.py +664 -0
- package/tools/silhouette_qa.py +910 -0
|
@@ -332,28 +332,25 @@ def _short_term_db(x: np.ndarray, win: float = 0.4, hop: float = 0.1,
|
|
|
332
332
|
|
|
333
333
|
|
|
334
334
|
def _approx_lufs(x: np.ndarray, sr: int = SR) -> float:
|
|
335
|
-
"""近似积分响度(简化 K 加权 + 400/100 ms 短时块 + -70/-10 dB 双门限)。
|
|
335
|
+
"""近似积分响度(简化 K 加权 + 400/100 ms 短时块 + -70/-10 dB 双门限)。
|
|
336
|
+
|
|
337
|
+
注意 10**(lj/10) 已经是"块均方 * 10**(-0.691/10)":lj 里的 -0.691 是 BS.1770
|
|
338
|
+
块响度定义的一部分,把它抬回"块均方"后,积分响度就是 10*log10(门限内均值)。
|
|
339
|
+
旧实现又在均值外面减了一次 0.691,等于报告值恒定偏低 0.69 dB
|
|
340
|
+
(实测 12 s 片:本文件 -15.65 vs sfxkit.integrated_loudness -14.47,差 1.19 dB;
|
|
341
|
+
去掉多余项后 -14.96,剩余差是"12 kHz 降采样 + 移动平均近似 K 加权"的固有误差)。
|
|
342
|
+
"""
|
|
336
343
|
_, lj = _short_term_db(x, 0.4, 0.1, sr=sr)
|
|
337
344
|
lj = lj[np.isfinite(lj)]
|
|
338
345
|
lj = lj[lj > -70.0]
|
|
339
346
|
if len(lj) == 0:
|
|
340
347
|
return -70.0
|
|
341
348
|
e = np.mean(10.0 ** (lj / 10.0))
|
|
342
|
-
rel =
|
|
349
|
+
rel = 10.0 * np.log10(max(e, 1e-20)) - 10.0
|
|
343
350
|
lj2 = lj[lj > rel]
|
|
344
351
|
if len(lj2) == 0:
|
|
345
|
-
return float(
|
|
346
|
-
return float(
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def _measure_section_db(x: np.ndarray, start: float, end: float, sr: int = SR) -> float:
|
|
350
|
-
"""段落内短时响度的中位数(避开段首尾 0.12 s 过渡;静音块不计)。"""
|
|
351
|
-
t, lj = _short_term_db(x, 0.4, 0.1, sr=sr)
|
|
352
|
-
a, b = float(start) + 0.12, float(end) - 0.12
|
|
353
|
-
sel = (t >= a) & (t <= b) & (lj > -70.0)
|
|
354
|
-
if not np.any(sel):
|
|
355
|
-
return -70.0
|
|
356
|
-
return float(np.median(lj[sel]))
|
|
352
|
+
return float(10.0 * np.log10(max(e, 1e-20)))
|
|
353
|
+
return float(10.0 * np.log10(max(float(np.mean(10.0 ** (lj2 / 10.0))), 1e-20)))
|
|
357
354
|
|
|
358
355
|
|
|
359
356
|
# ============================================================================
|
|
@@ -607,7 +604,11 @@ class _Bank:
|
|
|
607
604
|
env = np.minimum(1.0, t / max(attack, 1e-4))
|
|
608
605
|
rel = t > self._pad_dur
|
|
609
606
|
if np.any(rel):
|
|
610
|
-
|
|
607
|
+
# 释音必须是"乘上去"而不是"覆盖掉":起音比一小节还慢时(bpm > ~223 的
|
|
608
|
+
# 4/4,pad_dur = 一小节 * 1.02),覆盖式写法会在 t=pad_dur 处把还在爬坡的
|
|
609
|
+
# 包络(例如 0.68)直接跳到 1.0,听感是一声"噗";乘法写法在起音已完成时
|
|
610
|
+
# 是 1.0 * 衰减,逐位与原结果相同(既有 BPM 全部不受影响)。
|
|
611
|
+
env[rel] *= np.exp(-(t[rel] - self._pad_dur) / max(release, 1e-4))
|
|
611
612
|
env = _f32(env)
|
|
612
613
|
self._pad_envs[key] = env
|
|
613
614
|
return env
|
|
@@ -3055,10 +3056,6 @@ def _merge_sections(anchors, sections, duration: float) -> list:
|
|
|
3055
3056
|
"energy": _clip01(a.get("energy", KIND_ENERGY.get(kind, 0.5))),
|
|
3056
3057
|
"anchor_id": str(a.get("id", "")),
|
|
3057
3058
|
})
|
|
3058
|
-
def _anchor_end(key: str) -> float:
|
|
3059
|
-
hit = next((b for b in blocks if b.get("anchor_id") == key), None)
|
|
3060
|
-
return float(hit["end"]) if hit else float(duration)
|
|
3061
|
-
|
|
3062
3059
|
extra: list[dict] = []
|
|
3063
3060
|
for s in sections or []:
|
|
3064
3061
|
_require(isinstance(s, dict), "sections 里的每一项都必须是对象")
|
|
@@ -3179,17 +3176,6 @@ def load(path, sr: int = SR) -> np.ndarray:
|
|
|
3179
3176
|
# ============================================================================
|
|
3180
3177
|
# 自测
|
|
3181
3178
|
# ============================================================================
|
|
3182
|
-
def _demo_anchors(duration: float) -> list:
|
|
3183
|
-
"""selftest 用的小时间轴(按型:hook -> body -> drive -> outro)。"""
|
|
3184
|
-
q = duration / 4.0
|
|
3185
|
-
return [
|
|
3186
|
-
{"id": "a1", "kind": "hook", "start": 0.0, "end": q},
|
|
3187
|
-
{"id": "a2", "kind": "body", "start": q, "end": 2 * q},
|
|
3188
|
-
{"id": "a3", "kind": "drive", "start": 2 * q, "end": 3 * q},
|
|
3189
|
-
{"id": "a4", "kind": "outro", "start": 3 * q, "end": duration},
|
|
3190
|
-
]
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
3179
|
def _max_loudness_jump(x: np.ndarray, rep: dict, sr: int = SR) -> float:
|
|
3194
3180
|
"""段内相邻短时响度块的最大跳变(dB);跳过设计好的"吸气"窗口。"""
|
|
3195
3181
|
t, lj = _short_term_db(x, 0.3, 0.1, sr=sr)
|
|
@@ -47,6 +47,17 @@
|
|
|
47
47
|
41 (v0.7.2)空门禁账本不许写成"通过"(没记录 != 通过)
|
|
48
48
|
42 (v0.7.2)单阶段产物归属:不许把别的 stage 写的文件算成自己的(假 stale)
|
|
49
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 条,属于"自检清单自己没被自检"。
|
|
50
61
|
"""
|
|
51
62
|
import argparse
|
|
52
63
|
import io
|
|
@@ -1141,6 +1152,466 @@ def t_theme_overrides_linted():
|
|
|
1141
1152
|
return "最终主题参与体检(%s)" % ";".join(bad)[:60]
|
|
1142
1153
|
|
|
1143
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
|
+
|
|
1410
|
+
# ============================================================================
|
|
1411
|
+
# v0.9:场景语法 + 视觉语言
|
|
1412
|
+
# ============================================================================
|
|
1413
|
+
|
|
1414
|
+
def _load_grammar():
|
|
1415
|
+
"""加载 grammar.py(纯标准库)。找不到返回 None —— CI 里可能只拷了 scripts/ 的一部分。"""
|
|
1416
|
+
try:
|
|
1417
|
+
import grammar
|
|
1418
|
+
return grammar
|
|
1419
|
+
except Exception: # noqa: BLE001
|
|
1420
|
+
cand = os.path.join(HERE, "grammar.py")
|
|
1421
|
+
if not os.path.exists(cand):
|
|
1422
|
+
return None
|
|
1423
|
+
import importlib.util
|
|
1424
|
+
spec = importlib.util.spec_from_file_location("sc_grammar", cand)
|
|
1425
|
+
mod = importlib.util.module_from_spec(spec)
|
|
1426
|
+
spec.loader.exec_module(mod)
|
|
1427
|
+
return mod
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def _fake_scenes(G, lines, per=6.0):
|
|
1431
|
+
"""按固定时长造 timeline 段(不跑 TTS),只用来测选型。"""
|
|
1432
|
+
scenes, t = [], 0.0
|
|
1433
|
+
for ln in lines:
|
|
1434
|
+
row = dict(ln, start=round(t, 3), end=round(t + per, 3), dur=round(per, 3))
|
|
1435
|
+
scenes.append(row)
|
|
1436
|
+
t += per
|
|
1437
|
+
return scenes
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def t_scene_grammar():
|
|
1441
|
+
"""52 场景语法:10 类都能被选中、选型确定、段长门会降级。
|
|
1442
|
+
|
|
1443
|
+
为什么要有这条:v0.9 之前 quote/emphasis/calm 三种 kind 全部落到 body 绘制器,
|
|
1444
|
+
画面永远只有一套骨架。这条断言盯的是"内容 -> 构图"这条链路真的在分流,
|
|
1445
|
+
而不是"代码里写了 10 个函数但没人调用"。
|
|
1446
|
+
"""
|
|
1447
|
+
G = _load_grammar()
|
|
1448
|
+
if G is None:
|
|
1449
|
+
return "skip(没有 grammar.py)"
|
|
1450
|
+
lines = [
|
|
1451
|
+
{"id": "a", "kind": "hook", "chapter": "01", "text": "开场一句。", "big": "开场"},
|
|
1452
|
+
{"id": "b", "kind": "body", "chapter": "02", "text": "我们每天被打断 47 次。", "big": "47 次"},
|
|
1453
|
+
{"id": "c", "kind": "body", "chapter": "02", "text": "对比一下:深度工作四小时,产出是碎片时间的三倍。",
|
|
1454
|
+
"big": "四小时 vs 碎片", "items": [{"text": "深度工作"}, {"text": "碎片时间"}]},
|
|
1455
|
+
{"id": "d", "kind": "body", "chapter": "03", "text": "第一步清空收件箱,然后关掉通知,接着单线程,最后复盘。",
|
|
1456
|
+
"big": "四步", "items": [{"text": "清空"}, {"text": "关通知"}, {"text": "单线程"}, {"text": "复盘"}]},
|
|
1457
|
+
{"id": "e", "kind": "quote", "chapter": "03", "text": "他说:「深度工作稀缺。」", "quote": "深度工作稀缺"},
|
|
1458
|
+
{"id": "f", "kind": "body", "chapter": "04", "text": "各行业占比差异明显。", "big": "各行业占比",
|
|
1459
|
+
"items": [{"text": "A 62%"}, {"text": "B 51%"}, {"text": "C 38%"}]},
|
|
1460
|
+
{"id": "g", "kind": "body", "chapter": "05", "text": "注意力是换来的。", "big": "注意力是换来的"},
|
|
1461
|
+
{"id": "h", "kind": "body", "chapter": "06", "text": "第五章开始了。", "big": "第五章"},
|
|
1462
|
+
{"id": "i", "kind": "body", "chapter": "06", "text": "这里有五条要点要讲清楚。", "big": "五条要点",
|
|
1463
|
+
"items": [{"text": "一"}, {"text": "二"}, {"text": "三"}, {"text": "四"}, {"text": "五"}]},
|
|
1464
|
+
{"id": "j", "kind": "outro", "chapter": "99", "text": "开始就好。", "big": "开始就好"},
|
|
1465
|
+
]
|
|
1466
|
+
scenes = _fake_scenes(G, lines)
|
|
1467
|
+
plan = {"chapter_layout": {}, "layout_default": "left-stack", "style": "tech-deep"}
|
|
1468
|
+
sb1 = G.plan_scenes([dict(s) for s in scenes], {"title": "t"}, plan)
|
|
1469
|
+
got = [r["type"] for r in sb1["scenes"]]
|
|
1470
|
+
# ① 确定性:同一份输入两次结果完全一致(含变体号与理由)
|
|
1471
|
+
sb2 = G.plan_scenes([dict(s) for s in scenes], {"title": "t"}, plan)
|
|
1472
|
+
assert [r["type"] for r in sb2["scenes"]] == got, "同一份稿子两次选型不同(不确定性)"
|
|
1473
|
+
assert [r["variant"] for r in sb2["scenes"]] == [r["variant"] for r in sb1["scenes"]], \
|
|
1474
|
+
"同一份稿子两次变体不同(不确定性)"
|
|
1475
|
+
# ② 分流:至少命中 5 种不同类型(不是全退化成 list/statement)
|
|
1476
|
+
assert len(set(got)) >= 5, "10 段只分出 %d 种构图(%r):选型逻辑没在分流" % (len(set(got)), got)
|
|
1477
|
+
assert "stat" in got and "compare" in got and "quote" in got, \
|
|
1478
|
+
"数字/对比/引语三种特征没有各自分流:%r" % (got,)
|
|
1479
|
+
# ③ 段长门:把 chart 塞进 2 秒段必须降级,且理由里写明
|
|
1480
|
+
short = [{"id": "x", "kind": "body", "chapter": "01", "text": "短。", "big": "短",
|
|
1481
|
+
"type": "chart", "series": [{"label": "A", "value": 1}]}]
|
|
1482
|
+
sb3 = G.plan_scenes([dict(s) for s in _fake_scenes(G, short, per=2.0)],
|
|
1483
|
+
{"title": "t"}, plan)
|
|
1484
|
+
r3 = sb3["scenes"][0]
|
|
1485
|
+
assert r3["type"] != "chart", "2 秒的段仍选了 chart(段长门失效)"
|
|
1486
|
+
assert any("降级" in x or "排除" in x for x in r3["reason"]), \
|
|
1487
|
+
"降级没有留下理由:%r" % (r3["reason"],)
|
|
1488
|
+
# ④ 显式 type 优先
|
|
1489
|
+
ex = [{"id": "y", "kind": "body", "chapter": "01", "text": "随便一句", "big": "随便",
|
|
1490
|
+
"type": "hero"}]
|
|
1491
|
+
sb4 = G.plan_scenes([dict(s) for s in _fake_scenes(G, ex)], {"title": "t"}, plan)
|
|
1492
|
+
assert sb4["scenes"][0]["type"] == "hero", "显式 type=hero 没生效:%r" % (sb4["scenes"][0],)
|
|
1493
|
+
return "10 段 -> %d 种构图;降级 chart->%s;显式 type 生效" % (len(set(got)), r3["type"])
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def t_visual_languages():
|
|
1497
|
+
"""53 视觉语言包:8 套结构签名齐全、过体检、且彼此不是同一套。
|
|
1498
|
+
|
|
1499
|
+
为什么要有这条:v0.5 的"风格"只改配色,8 套风格骨架完全一样(用户抱怨的根因)。
|
|
1500
|
+
这条盯的是"结构签名真的存在且互不相同"——母题/转场/重心偏移任一维度相同不算问题,
|
|
1501
|
+
但**五组签名全同**就说明这套语言只是复制粘贴。
|
|
1502
|
+
"""
|
|
1503
|
+
import stylekit as ST
|
|
1504
|
+
if not hasattr(ST, "VISUAL_LANGUAGES"):
|
|
1505
|
+
return "skip(stylekit 没有 VISUAL_LANGUAGES)"
|
|
1506
|
+
bad = ST.visual_lint_all()
|
|
1507
|
+
assert not bad, "内置视觉语言没过体检: %s" % bad
|
|
1508
|
+
sigs = {}
|
|
1509
|
+
for sid in ST.STYLE_IDS:
|
|
1510
|
+
vl = ST.VISUAL_LANGUAGES[sid]
|
|
1511
|
+
key = (str((vl.get("motif") or {}).get("kind")),
|
|
1512
|
+
str((vl.get("transition") or {}).get("kind")),
|
|
1513
|
+
tuple(sorted((vl.get("bias") or {}).items())))
|
|
1514
|
+
assert vl.get("motion") and vl.get("typography"), "%s 缺 motion/typography 签名" % sid
|
|
1515
|
+
sigs.setdefault(key, []).append(sid)
|
|
1516
|
+
dup = [v for v in sigs.values() if len(v) > 1]
|
|
1517
|
+
assert not dup, "这些风格的母题/转场/重心完全相同(等于没做结构差异): %s" % (dup,)
|
|
1518
|
+
motifs = {str((ST.VISUAL_LANGUAGES[s].get("motif") or {}).get("kind")) for s in ST.STYLE_IDS}
|
|
1519
|
+
assert len(motifs) >= 6, "8 套风格只用了 %d 种装饰母题:%r" % (len(motifs), sorted(motifs))
|
|
1520
|
+
return "8 套 / %d 种母题 / %d 种转场 / 无重复签名" % (
|
|
1521
|
+
len(motifs),
|
|
1522
|
+
len({str((ST.VISUAL_LANGUAGES[s].get("transition") or {}).get("kind"))
|
|
1523
|
+
for s in ST.STYLE_IDS}))
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def t_scene_types_single_truth():
|
|
1527
|
+
"""54 场景类型只有一份"真值集合":4 处定义必须集合相等(顺序可以不同)。
|
|
1528
|
+
|
|
1529
|
+
为什么比集合不比顺序:grammar.SCENE_TYPES 的顺序是"平手取位顺序",改它会改变同一份
|
|
1530
|
+
稿子的选型结果,所以它有独立语义;filmcheck/schema/scenes 只关心"有哪些类型"。
|
|
1531
|
+
顺序不同是设计,集合不同是 bug(写错一个字母 = 某个类型永远画不出来)。
|
|
1532
|
+
"""
|
|
1533
|
+
G = _load_grammar()
|
|
1534
|
+
if G is None:
|
|
1535
|
+
return "skip(没有 grammar.py)"
|
|
1536
|
+
here_scripts = HERE
|
|
1537
|
+
got = {"grammar": set(G.SCENE_TYPES)}
|
|
1538
|
+
# filmcheck.SCENE_TYPES
|
|
1539
|
+
try:
|
|
1540
|
+
import filmcheck as FC
|
|
1541
|
+
got["filmcheck"] = set(FC.SCENE_TYPES)
|
|
1542
|
+
except Exception: # noqa: BLE001
|
|
1543
|
+
pass
|
|
1544
|
+
# assets/scenes.py 的 _DRAWERS 键
|
|
1545
|
+
mod = _load_scenes_mod()
|
|
1546
|
+
if mod is not None and hasattr(mod, "_DRAWERS"):
|
|
1547
|
+
got["scenes"] = set(mod._DRAWERS.keys())
|
|
1548
|
+
# schema 的 enum
|
|
1549
|
+
schema = os.path.join(os.path.dirname(os.path.dirname(here_scripts)),
|
|
1550
|
+
"..", "..", "schemas", "film.schema.json")
|
|
1551
|
+
schema = os.path.normpath(schema)
|
|
1552
|
+
if os.path.exists(schema):
|
|
1553
|
+
try:
|
|
1554
|
+
data = json.load(open(schema, encoding="utf-8"))
|
|
1555
|
+
enum = (data["properties"]["lines"]["items"]["properties"]["type"]["enum"])
|
|
1556
|
+
got["schema"] = set(enum)
|
|
1557
|
+
except Exception: # noqa: BLE001
|
|
1558
|
+
pass
|
|
1559
|
+
if len(got) < 3:
|
|
1560
|
+
return "skip(只找到 %d 处真值:%s)" % (len(got), sorted(got))
|
|
1561
|
+
base_name, base = sorted(got.items())[0]
|
|
1562
|
+
for name, s in got.items():
|
|
1563
|
+
if s != base:
|
|
1564
|
+
raise AssertionError("场景类型集合不一致:%s 与 %s 差 %r / %r"
|
|
1565
|
+
% (name, base_name, sorted(s - base), sorted(base - s)))
|
|
1566
|
+
return "%d 处真值集合一致(%d 类):%s" % (len(got), len(base), "/".join(sorted(got)))
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def t_silhouette_gate():
|
|
1570
|
+
"""55 剪影可区分(G13):8 套风格两两之间构图骨架必须能区分。
|
|
1571
|
+
|
|
1572
|
+
这是 v0.9 的核心验收:如果这条挂了,说明"换风格只是换色"的毛病回来了。
|
|
1573
|
+
工具在 tools/silhouette_qa.py(可选):缺了 skip,不阻塞 CI。
|
|
1574
|
+
"""
|
|
1575
|
+
root = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
|
|
1576
|
+
tool = os.path.join(root, "tools", "silhouette_qa.py")
|
|
1577
|
+
if not os.path.exists(tool):
|
|
1578
|
+
return "skip(没有 tools/silhouette_qa.py)"
|
|
1579
|
+
p = subprocess.run([sys.executable, tool, "--json"],
|
|
1580
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1581
|
+
cwd=root)
|
|
1582
|
+
out = (p.stdout or "").strip()
|
|
1583
|
+
i = out.find("{")
|
|
1584
|
+
if i < 0:
|
|
1585
|
+
return "skip(silhouette_qa 没输出 JSON:%s)" % ((p.stderr or out) or "")[:120]
|
|
1586
|
+
try:
|
|
1587
|
+
d = json.loads(out[i:])
|
|
1588
|
+
except json.JSONDecodeError as e:
|
|
1589
|
+
return "skip(silhouette_qa JSON 解析失败:%s)" % e
|
|
1590
|
+
pairs = d.get("pairs") or []
|
|
1591
|
+
# 三档语义与工具对齐:status 只有 pass / fail / skip 三种。
|
|
1592
|
+
# 只认 returncode 会把"工具没渲出帧"(skip)也记成 PASS —— 那正是本仓库反复踩的坑。
|
|
1593
|
+
failed = [x for x in pairs if str(x.get("status")) == "fail"]
|
|
1594
|
+
skipped = [x for x in pairs if str(x.get("status")) == "skip"]
|
|
1595
|
+
if p.returncode != 0 or failed:
|
|
1596
|
+
names = ["%s×%s" % (x.get("a"), x.get("b")) for x in failed[:4]]
|
|
1597
|
+
raise AssertionError("剪影不可区分:%d/%d 对失败(%s);换风格仍只是换色"
|
|
1598
|
+
% (len(failed), len(pairs), ", ".join(names)))
|
|
1599
|
+
if skipped:
|
|
1600
|
+
return "skip(%d/%d 对没渲出来:%s)" % (
|
|
1601
|
+
len(skipped), len(pairs),
|
|
1602
|
+
", ".join("%s×%s" % (x.get("a"), x.get("b")) for x in skipped[:3]))
|
|
1603
|
+
# 同明暗族是"保守下界":跨族很多时候靠 ΔB(背景亮度差)就能过,那不是构图证据。
|
|
1604
|
+
# 所以这条断言在报告里必须把同族数字单独说出来,别让 28/28 被读成"8 套风格结构全不同"。
|
|
1605
|
+
same = ((d.get("summary") or {}).get("by_family") or {}).get("same") or {}
|
|
1606
|
+
same_n = same.get("pairs")
|
|
1607
|
+
same_ok = same.get("pass")
|
|
1608
|
+
if same_n and same_ok is not None and int(same_ok) < int(same_n):
|
|
1609
|
+
raise AssertionError("同明暗族只有 %s/%s 对可区分(保守下界不达标)"
|
|
1610
|
+
% (same_ok, same_n))
|
|
1611
|
+
return "%d 对全部可区分(同明暗族 %s/%s,保守下界也过)" % (
|
|
1612
|
+
len(pairs), same_ok if same_ok is not None else "?", same_n if same_n else "?")
|
|
1613
|
+
|
|
1614
|
+
|
|
1144
1615
|
CASES = [
|
|
1145
1616
|
("1 TTS 缓存校验文本", t_cache_key),
|
|
1146
1617
|
("2 sub_window 覆盖长句", t_sub_window),
|
|
@@ -1185,6 +1656,18 @@ CASES = [
|
|
|
1185
1656
|
("41 空门禁账本不许写成通过", t_empty_ledger_not_pass),
|
|
1186
1657
|
("42 单阶段产物归属(防假 stale)", t_stage_io_no_phantom_output),
|
|
1187
1658
|
("43 theme_overrides 参与主题体检", t_theme_overrides_linted),
|
|
1659
|
+
("44 词级时间戳(规整/裁剪/锚点)", t_word_events),
|
|
1660
|
+
("45 画幅规划(只允许横屏下采样)", t_video_plan),
|
|
1661
|
+
("46 金帧确定性(同帧两次一致)", t_golden_determinism),
|
|
1662
|
+
("47 依赖漂移体检(离线)", t_dependency_drift),
|
|
1663
|
+
("48 批量清单解析(预检先于执行)", t_batch_manifest),
|
|
1664
|
+
("49 TTS 后端可替换(接口契约)", t_tts_backend),
|
|
1665
|
+
("50 web 风格载荷(legacy 必须回落)", t_web_style_payload),
|
|
1666
|
+
("51 金帧回归(像素基线)", t_golden_frames),
|
|
1667
|
+
("52 场景语法(10 类分流/确定/段长门)", t_scene_grammar),
|
|
1668
|
+
("53 视觉语言包(结构签名互不相同)", t_visual_languages),
|
|
1669
|
+
("54 场景类型单真值(4 处集合一致)", t_scene_types_single_truth),
|
|
1670
|
+
("55 剪影可区分(G13:换风格不能只是换色)", t_silhouette_gate),
|
|
1188
1671
|
]
|
|
1189
1672
|
|
|
1190
1673
|
|