@dsh-gis/dsh-video-studio-skill 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +951 -0
- package/README.md +51 -8
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +4 -1
- package/package.json +5 -3
- package/schemas/film.schema.json +162 -2
- package/skills/video-studio/SKILL.md +211 -11
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/web/engine.js +63 -2
- package/skills/video-studio/assets/web/film.js +224 -101
- package/skills/video-studio/assets/web/index.html +4 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +4 -3
- package/skills/video-studio/references/audio.md +2 -2
- package/skills/video-studio/references/change-impact.md +1 -1
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +14 -0
- package/skills/video-studio/scripts/filmcheck.py +121 -0
- package/skills/video-studio/scripts/filmkit.py +521 -38
- package/skills/video-studio/scripts/gates.py +88 -4
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +49 -8
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +719 -32
- package/skills/video-studio/scripts/scorekit.py +16 -30
- package/skills/video-studio/scripts/selfcheck.py +274 -0
- package/skills/video-studio/scripts/sfxkit.py +130 -14
- package/skills/video-studio/scripts/stagewatch.py +19 -6
- package/skills/video-studio/scripts/stylekit.py +43 -12
- package/skills/video-studio/scripts/voicekit.py +15 -7
- package/skills/video-studio/scripts/webengine.py +1119 -72
- package/tools/ci_check.py +171 -31
- package/tools/estimate.py +130 -19
- package/tools/golden_frames.py +664 -0
|
@@ -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,261 @@ 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
|
+
|
|
1144
1410
|
CASES = [
|
|
1145
1411
|
("1 TTS 缓存校验文本", t_cache_key),
|
|
1146
1412
|
("2 sub_window 覆盖长句", t_sub_window),
|
|
@@ -1185,6 +1451,14 @@ CASES = [
|
|
|
1185
1451
|
("41 空门禁账本不许写成通过", t_empty_ledger_not_pass),
|
|
1186
1452
|
("42 单阶段产物归属(防假 stale)", t_stage_io_no_phantom_output),
|
|
1187
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),
|
|
1188
1462
|
]
|
|
1189
1463
|
|
|
1190
1464
|
|
|
@@ -782,6 +782,33 @@ def tv_filter(x, kind: str, freq, q: float = 0.7071, gain_db: float = 0.0,
|
|
|
782
782
|
return y
|
|
783
783
|
|
|
784
784
|
|
|
785
|
+
def _resonator_ir(f: float, t60: float, amp: float, n_max: int,
|
|
786
|
+
sr: int = SR) -> np.ndarray:
|
|
787
|
+
"""模态谐振器的解析冲激响应 h[n] = amp · r^n · sin(ωn),长度 ≤ n_max。
|
|
788
|
+
|
|
789
|
+
为什么不复用通用的 _iir_ir():无 scipy 时 resonator 也要走 FFT 卷积,而金属/
|
|
790
|
+
玻璃模态的 t60 可达 5~7 s(见 MATERIALS 的 eta 量级),-80 dB 衰减长度约
|
|
791
|
+
1.33·t60·sr ≈ 40 万采样。旧的 _iir_ir(cap=20000) 在 0.42 s 处把 IR 砍成 0:
|
|
792
|
+
截断点在 -25 dB 上直接跳变,听感是长尾里多一下“咔”,且与 scipy 路径的
|
|
793
|
+
产物长度/尾音全不同(references/sfx-design.md §12 要求静态滤波与 scipy
|
|
794
|
+
相对误差 ≤ 2e-4,实测旧兜底最差 -8 dB)。这个二阶系统的 IR 有闭式解,
|
|
795
|
+
一次向量化算完,比逐样本 Python 递推快两三个数量级。
|
|
796
|
+
长度取 min(n_max, -80 dB 衰减长度):输出只取前 n_max 个采样,h 超过 n_max
|
|
797
|
+
的部分不可能影响这些采样,所以这个截断是精确的、不是近似。
|
|
798
|
+
"""
|
|
799
|
+
n_max = int(max(0, n_max))
|
|
800
|
+
if n_max == 0:
|
|
801
|
+
return np.zeros(0)
|
|
802
|
+
a1 = math.exp(-LN1000 / (max(float(t60), 1e-5) * float(sr)))
|
|
803
|
+
w = 2.0 * np.pi * float(f) / float(sr)
|
|
804
|
+
# 借 _ir_len() 的极点半径估计(它已处理不衰减/数值退化),保证与旧路径同口径
|
|
805
|
+
a = np.array([1.0, -2.0 * a1 * math.cos(w), a1 * a1])
|
|
806
|
+
b = np.array([0.0, float(amp) * a1 * math.sin(w)])
|
|
807
|
+
L = int(_ir_len(b, a, cap=n_max))
|
|
808
|
+
k = np.arange(L, dtype=float)
|
|
809
|
+
return float(amp) * np.power(a1, k) * np.sin(w * k)
|
|
810
|
+
|
|
811
|
+
|
|
785
812
|
def resonator(excitation, f: float, t60: float, amp: float = 1.0,
|
|
786
813
|
sr: int = SR) -> np.ndarray:
|
|
787
814
|
"""单个模态:二阶谐振器,冲激响应为 amp · r^n · sin(ωn)(指数衰减的正弦)。"""
|
|
@@ -794,7 +821,7 @@ def resonator(excitation, f: float, t60: float, amp: float = 1.0,
|
|
|
794
821
|
a = np.array([1.0, -2.0 * a1 * math.cos(w), a1 * a1])
|
|
795
822
|
if _sig is not None:
|
|
796
823
|
return _sig.lfilter(b, a, x, axis=0)
|
|
797
|
-
return _fftconv(x,
|
|
824
|
+
return _fftconv(x, _resonator_ir(f, t60, amp, len(x), sr))[: len(x)]
|
|
798
825
|
|
|
799
826
|
|
|
800
827
|
def resonator_bank(excitation, freqs, t60s, amps, sr: int = SR) -> np.ndarray:
|
|
@@ -1000,13 +1027,31 @@ class Body:
|
|
|
1000
1027
|
y = np.zeros(n)
|
|
1001
1028
|
if not events:
|
|
1002
1029
|
return y
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1030
|
+
if _sig is not None:
|
|
1031
|
+
# 有 scipy:逐模态 lfilter(保持 v0.7 及以前的产物逐样本不变——用户成片不出新声音)
|
|
1032
|
+
exc = np.zeros(n)
|
|
1033
|
+
for i, (f, t60) in enumerate(zip(self.freqs, self.t60s)):
|
|
1034
|
+
exc[:] = 0.0
|
|
1035
|
+
for s, pulse, w in events:
|
|
1036
|
+
exc[s:s + len(pulse)] += w[i] * pulse
|
|
1037
|
+
y += resonator(exc, float(f), float(t60), 1.0, sr)
|
|
1038
|
+
return y
|
|
1039
|
+
# 无 scipy:先按事件把「加权模态 IR」合成一条,再与力脉冲卷积一次(N 模态 → 1 次 FFT)。
|
|
1040
|
+
# 等价性:每个事件的模态权重 w_i 是标量,Σ_i w_i·conv(pulse, h_i) = conv(pulse, Σ_i w_i·h_i)
|
|
1041
|
+
# 可换序;所以合并只减少 FFT 次数,不改变结果。只在兜底路径合并:有 scipy 的产物保持原样。
|
|
1042
|
+
# 代价:IR 长度仍按 -80 dB 衰减截断,且只保留到输出末尾(h 超过 n-s 的部分影响不到输出)。
|
|
1043
|
+
for s, pulse, w in events:
|
|
1044
|
+
lcap = n - s
|
|
1045
|
+
if lcap <= 0 or len(pulse) == 0:
|
|
1046
|
+
continue
|
|
1047
|
+
h = np.zeros(lcap)
|
|
1048
|
+
for i, (f, t60) in enumerate(zip(self.freqs, self.t60s)):
|
|
1049
|
+
if w[i] == 0.0:
|
|
1050
|
+
continue
|
|
1051
|
+
hi = _resonator_ir(float(f), float(t60), float(w[i]), lcap, sr)
|
|
1052
|
+
h[: len(hi)] += hi
|
|
1053
|
+
seg = _fftconv(pulse, h)[:lcap]
|
|
1054
|
+
y[s:s + len(seg)] += seg
|
|
1010
1055
|
return y
|
|
1011
1056
|
|
|
1012
1057
|
|
|
@@ -2436,6 +2481,11 @@ def data_chirp(seed=0, dur=0.8, rate=28.0, base=1800.0, **kw):
|
|
|
2436
2481
|
v0.7:音高跟随 **kw 里的 key(None = 旧行为 D 小调五声)。
|
|
2437
2482
|
"""
|
|
2438
2483
|
r = float(rate)
|
|
2484
|
+
# rate 是"每秒脉冲数",必须为正:rate=0 会在下面 i / r 抛 ZeroDivisionError(内部
|
|
2485
|
+
# 堆栈,用户看不出是参数问题);rate<0 时 int(dur*r) 为负、事件时刻为负,_place 会
|
|
2486
|
+
# 静默丢掉大部分脉冲——只剩哑音。与 _enum 同口径:认识但非法的值必须响亮报错。
|
|
2487
|
+
if not math.isfinite(r) or r <= 0.0:
|
|
2488
|
+
raise ValueError("data_chirp: rate 必须是正数(每秒脉冲数),收到 %r" % (rate,))
|
|
2439
2489
|
n = max(2, int(float(dur) * r))
|
|
2440
2490
|
g = rng(seed)
|
|
2441
2491
|
x = _bus(dur, stereo=False)
|
|
@@ -3239,19 +3289,85 @@ def selftest() -> int:
|
|
|
3239
3289
|
return "环境声响度贴合 master(±1.5 LU)"
|
|
3240
3290
|
|
|
3241
3291
|
def t_fallback():
|
|
3292
|
+
# v0.8:这条以前只查"输出是有限值"——等于只验了"没崩",兜底路径的幅度
|
|
3293
|
+
# 正确性完全没查(references/sfx-design.md §12 承诺静态滤波与 scipy 相对
|
|
3294
|
+
# 误差 ≤ 2e-4)。现在按当前环境选参照:
|
|
3295
|
+
# 有 scipy:同一函数跑两遍(scipy 路径 vs 兜底路径)逐样本比 —— 直接验"等价";
|
|
3296
|
+
# 无 scipy:没有 scipy 路径可比,改用**解析参照**(双二阶的阶跃稳态/频响、
|
|
3297
|
+
# 谐振器的闭式冲激响应 amp·r^n·sin(ωn)),这样 CI 的 noscipy 任务
|
|
3298
|
+
# 也在查"数值对不对",而不是自己和自己比出一个假 PASS。
|
|
3242
3299
|
old = globals().get("_sig")
|
|
3300
|
+
has_ref = old is not None
|
|
3301
|
+
S = globals()
|
|
3243
3302
|
try:
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3303
|
+
x = np.random.default_rng(0).standard_normal(24000) * 0.1
|
|
3304
|
+
worst = 0.0
|
|
3305
|
+
|
|
3306
|
+
def cmp_paths(fn):
|
|
3307
|
+
"""有 scipy 时:两条路径逐样本相对误差;无 scipy 时返回 None。"""
|
|
3308
|
+
if not has_ref:
|
|
3309
|
+
return None
|
|
3310
|
+
S["_sig"] = old
|
|
3311
|
+
a = fn()
|
|
3312
|
+
S["_sig"] = None
|
|
3313
|
+
b = fn()
|
|
3314
|
+
d = float(np.max(np.abs(a - b))) / max(float(np.max(np.abs(a))), 1e-12)
|
|
3315
|
+
return d
|
|
3316
|
+
|
|
3317
|
+
# 1) 静态滤波:有 scipy 比路径;无 scipy 用"阶跃稳态 = 解析 DC 增益"
|
|
3318
|
+
for kind, f0, dc in (("lowpass", 1000.0, 1.0), ("highpass", 1000.0, 0.0)):
|
|
3319
|
+
S["_sig"] = None
|
|
3320
|
+
step = np.concatenate([np.zeros(4800), np.ones(43200)])
|
|
3321
|
+
y = sosfilt(biquad(kind, f0), step)
|
|
3322
|
+
err = abs(float(np.mean(y[-1000:])) - dc)
|
|
3323
|
+
if err > 2e-3:
|
|
3324
|
+
raise ValueError("无 scipy %s(%g) 阶跃稳态 %.6f 偏离 DC 增益 %.3f"
|
|
3325
|
+
% (kind, f0, float(np.mean(y[-1000:])), dc))
|
|
3326
|
+
worst = max(worst, err)
|
|
3327
|
+
d = cmp_paths(lambda: lowpass(x, 1000.0))
|
|
3328
|
+
if d is not None:
|
|
3329
|
+
if d > 2e-4:
|
|
3330
|
+
raise ValueError("无 scipy 静态滤波与 scipy 相对误差 %.2e > 2e-4" % d)
|
|
3331
|
+
worst = max(worst, d)
|
|
3332
|
+
|
|
3333
|
+
# 2) 谐振器:闭式解。h[n] = amp·r^n·sin(ωn)(本函数构造的 b/a 决定的)。
|
|
3334
|
+
# 注意激励必须**够长**:旧兜底把 IR 截断在 20000 采样,若只喂 4096 点冲激,
|
|
3335
|
+
# 输出前 4096 点与解析解一致,截断根本测不出来(第一版断言就踩过这个坑)。
|
|
3336
|
+
# 这里用 30000 点白噪声:t60=3 s 的 -80 dB 长度约 19.2 万采样,输出第 2 万点之后
|
|
3337
|
+
# 的错误才会暴露,相对误差会到 3e-2 量级(远大于下面 2e-4 的阈值)。
|
|
3338
|
+
n = 30000
|
|
3339
|
+
exc = np.random.default_rng(7).standard_normal(n)
|
|
3340
|
+
for f, t60, amp in ((300.0, 1.0, 0.7), (220.0, 3.0, 1.0)):
|
|
3341
|
+
r0 = math.exp(-LN1000 / (t60 * float(SR)))
|
|
3342
|
+
w0 = 2.0 * math.pi * f / float(SR)
|
|
3343
|
+
k = np.arange(n, dtype=float)
|
|
3344
|
+
h_ref = amp * np.power(r0, k) * np.sin(w0 * k) # 解析 IR(独立于被测代码)
|
|
3345
|
+
# 参照卷积用 FFT(30000×30000 直接 np.convolve 是 9e8 次乘加,实测要 20 s,
|
|
3346
|
+
# 会把这个自检从 60 s 预算里挤爆);零填充到 2 的幂即可,长度不足部分不影响
|
|
3347
|
+
# 前 n 个输出采样。
|
|
3348
|
+
L = n + len(h_ref) - 1
|
|
3349
|
+
N = 1 << int(L - 1).bit_length()
|
|
3350
|
+
ref = np.fft.irfft(np.fft.rfft(exc, N) * np.fft.rfft(h_ref, N), N)[:n]
|
|
3351
|
+
S["_sig"] = None
|
|
3352
|
+
h = resonator(exc, f, t60, amp)
|
|
3353
|
+
err = float(np.max(np.abs(h - ref))) / max(float(np.max(np.abs(ref))), 1e-12)
|
|
3354
|
+
if err > 2e-4:
|
|
3355
|
+
raise ValueError("无 scipy 谐振器 f=%g t60=%g 与闭式解相对误差 %.2e > 2e-4"
|
|
3356
|
+
"(IR 被截断?)" % (f, t60, err))
|
|
3357
|
+
worst = max(worst, err)
|
|
3358
|
+
|
|
3359
|
+
# 3) 时变滤波:文档承认"块中心系数 + 重叠相加"是近似(高频控制信号会平滑),
|
|
3360
|
+
# 只查能跑 + 不出 NaN/Inf,不设等价阈值。
|
|
3361
|
+
S["_sig"] = None
|
|
3249
3362
|
z = tv_filter(x, "bandpass", np.linspace(300.0, 4000.0, len(x)), 0.9)
|
|
3250
3363
|
if not np.all(np.isfinite(z)):
|
|
3251
3364
|
raise ValueError("无 scipy 兜底 tv_filter 输出非有限值")
|
|
3252
3365
|
finally:
|
|
3253
3366
|
globals()["_sig"] = old
|
|
3254
|
-
|
|
3367
|
+
if has_ref:
|
|
3368
|
+
return "兜底 vs scipy:静态滤波/谐振器 ≤2e-4(实测 %.1e)/ tv_filter 可用" % worst
|
|
3369
|
+
return ("无 scipy 环境:静态滤波阶跃稳态 DC 增益 / 谐振器闭式解 均 ≤2e-3(实测 %.1e)+ "
|
|
3370
|
+
"tv_filter 可用(本环境没有 scipy 路径可比,用解析参照)" % worst)
|
|
3255
3371
|
|
|
3256
3372
|
def t_analysis():
|
|
3257
3373
|
m = analyze(render("impact", seed=6))
|
|
@@ -143,7 +143,7 @@ def _producer_of(art):
|
|
|
143
143
|
|
|
144
144
|
# 硬类型:这些永远参与 --strict-stale 判定(产物丢了 / 源头改了 / 上游自己就过时)
|
|
145
145
|
HARD_KINDS = ("source-changed", "output-missing", "no-manifest", "input-stale")
|
|
146
|
-
SOFT_KINDS = ("input-pending",)
|
|
146
|
+
SOFT_KINDS = ("input-pending", "source-pending")
|
|
147
147
|
|
|
148
148
|
|
|
149
149
|
def _src_fp(kind, sources):
|
|
@@ -234,14 +234,27 @@ def check(work, stage, sources=None, self_built=False, pending=(), _seen=None):
|
|
|
234
234
|
for kind in (io.get("sources") or []):
|
|
235
235
|
now = _src_fp(kind, sources)
|
|
236
236
|
was = old_src.get(kind, "missing")
|
|
237
|
-
if now
|
|
238
|
-
|
|
237
|
+
if now == was:
|
|
238
|
+
continue
|
|
239
|
+
fname = {"script": "剧本 film.json", "scenes": "画面代码 scenes.py"}.get(kind, kind)
|
|
240
|
+
if str(stage) in planned:
|
|
241
|
+
# 源头变了 + 本轮马上就要重跑本阶段 = 正常待办,不是过时。
|
|
242
|
+
# 不软化的话,「改一句台词 -> --stage all --strict-stale」会在第一个 stage
|
|
243
|
+
# 直接失败("没重跑却装了旧内容"——其实马上就要重跑),把主工作流堵死;
|
|
244
|
+
# 与 inputs 的 input-pending 是同一套语义,只是源头走的是另一个循环。
|
|
239
245
|
issues.append(dict(
|
|
240
|
-
stage=str(stage), kind="source-
|
|
241
|
-
reason="%s
|
|
242
|
-
"
|
|
246
|
+
stage=str(stage), kind="source-pending", file=kind, soft=True,
|
|
247
|
+
reason="%s 在上次跑本阶段之后变了(%s -> %s);本阶段本轮会重跑,"
|
|
248
|
+
"按新内容重算" % (fname, was, now),
|
|
243
249
|
rerun=RERUN_HINT.get(str(stage), ""),
|
|
244
250
|
))
|
|
251
|
+
continue
|
|
252
|
+
issues.append(dict(
|
|
253
|
+
stage=str(stage), kind="source-changed", file=kind,
|
|
254
|
+
reason="%s 在本阶段上次跑完之后改过(%s -> %s),但本阶段没有重跑;"
|
|
255
|
+
"它现在装的还是旧内容" % (fname, was, now),
|
|
256
|
+
rerun=RERUN_HINT.get(str(stage), ""),
|
|
257
|
+
))
|
|
245
258
|
old_inputs = rec.get("inputs") or {}
|
|
246
259
|
for name in io["inputs"]:
|
|
247
260
|
now = _fp(name, work)
|