@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.7.2
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/README.md +93 -18
- package/docs/styles/demo-results.json +76 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/index.js +20 -7
- package/package.json +13 -3
- package/schemas/film.schema.json +599 -0
- package/skills/video-studio/SKILL.md +252 -28
- package/skills/video-studio/assets/film.json +78 -24
- package/skills/video-studio/assets/scenes.py +1005 -42
- package/skills/video-studio/assets/web/engine.js +18 -1
- package/skills/video-studio/assets/web/film.js +29 -4
- package/skills/video-studio/assets/web/index.html +5 -0
- package/skills/video-studio/references/audio-variation.md +158 -0
- package/skills/video-studio/references/audio.md +96 -6
- package/skills/video-studio/references/change-impact.md +54 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/style-packs.md +178 -0
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/requirements.txt +13 -1
- package/skills/video-studio/scripts/filmcheck.py +725 -0
- package/skills/video-studio/scripts/filmkit.py +953 -76
- package/skills/video-studio/scripts/gates.py +509 -0
- package/skills/video-studio/scripts/qa_frames.py +109 -19
- package/skills/video-studio/scripts/render.py +942 -45
- package/skills/video-studio/scripts/scorekit.py +1887 -27
- package/skills/video-studio/scripts/selfcheck.py +648 -2
- package/skills/video-studio/scripts/sfxkit.py +132 -30
- package/skills/video-studio/scripts/stagewatch.py +405 -0
- package/skills/video-studio/scripts/stylekit.py +1012 -0
- package/skills/video-studio/scripts/voicekit.py +880 -0
- package/skills/video-studio/scripts/webengine.py +68 -14
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +450 -0
- package/tools/estimate.py +657 -0
- package/tools/selftest_noscipy.py +70 -0
|
@@ -28,6 +28,25 @@
|
|
|
28
28
|
22 (v0.3)webengine 可用时:页面契约齐全(window.renderFrame/getCues/ready)
|
|
29
29
|
23 (v0.3)qa_frames 可用时:能检出注入的单帧闪烁
|
|
30
30
|
24 (v0.4.0)配乐来源:music.mode=off 不读上一轮 work/music.wav(模式判定回归)
|
|
31
|
+
25 (v0.5.0)风格层:8 套主题全部过对比度门禁,8 类题材能分出 >=6 种风格
|
|
32
|
+
26 (v0.5.0)legacy 兼容:没写 style 字段必须精确落回 Dm/120/seed7/单声线
|
|
33
|
+
27 (v0.5.0)内容指纹:同剧本稳定、改标点不换风格、换题材才换
|
|
34
|
+
28 (v0.5.0)声线计划:4 句样片 >=2 把声音,legacy 仍是单声线
|
|
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 必须参与主题体检(否则能造出"看不清"的成片)
|
|
31
50
|
"""
|
|
32
51
|
import argparse
|
|
33
52
|
import io
|
|
@@ -63,7 +82,9 @@ def _setup_stdio():
|
|
|
63
82
|
def run(name, fn):
|
|
64
83
|
try:
|
|
65
84
|
detail = fn()
|
|
66
|
-
|
|
85
|
+
# 前缀判定:SKIP 的理由要能写给人看("skip(缺 xxx.py)"),但 "skipped != 通过"
|
|
86
|
+
# 这条语义不能被理由文字冲掉——旧实现只认精确 "skip",带理由的降级会被记成 PASS。
|
|
87
|
+
if str(detail or "").strip().lower().startswith("skip"):
|
|
67
88
|
RESULTS.append(("SKIP", name, "-"))
|
|
68
89
|
print(" SKIP %s" % name)
|
|
69
90
|
else:
|
|
@@ -425,7 +446,11 @@ def t_music_plan():
|
|
|
425
446
|
file_peak = float(np.max(np.abs(K.wav_read(file_out)[0])))
|
|
426
447
|
assert "music.mode=off" in buf.getvalue(), "off 没有打印说明(静默跳过也不行)"
|
|
427
448
|
assert off_peak < 0.01, "music.mode=off 仍把旧 music.wav 混进来了(峰值 %.3f)" % off_peak
|
|
428
|
-
|
|
449
|
+
# v0.7.1:断言改成"相对 off 明显变大"而不是绝对 0.3——
|
|
450
|
+
# 配乐基线降到 -12 dB(旁白优先)后,0.8 幅度的外部配乐混出来约 0.2;
|
|
451
|
+
# 绝对阈值会把"旁白优先调好了"判成"没读到外部配乐",这类断言会被调参牵着走。
|
|
452
|
+
assert file_peak > 0.1 and file_peak > off_peak * 3.0, (
|
|
453
|
+
"music.mode=file 没有用外部配乐(off %.3f -> file %.3f)" % (off_peak, file_peak))
|
|
429
454
|
return "off 峰值 %.3f / file 峰值 %.3f" % (off_peak, file_peak)
|
|
430
455
|
|
|
431
456
|
|
|
@@ -514,6 +539,608 @@ def t_qa_frames():
|
|
|
514
539
|
return "5 个函数齐全"
|
|
515
540
|
|
|
516
541
|
|
|
542
|
+
|
|
543
|
+
def t_style_kits():
|
|
544
|
+
"""25 风格层:8 套主题过对比度门禁 + 自动选风格能把 8 类题材分开。"""
|
|
545
|
+
import stylekit as ST
|
|
546
|
+
bad = ST.lint_all()
|
|
547
|
+
assert not bad, "内置主题没过对比度门禁: %s" % bad
|
|
548
|
+
topics = {
|
|
549
|
+
"tech": "AI 算法 模型 数据 架构 开源 编程 大模型 代码 系统",
|
|
550
|
+
"report": "报告 复盘 年度 总结 研究 分析 调研 白皮书 趋势 发布",
|
|
551
|
+
"craft": "手工 自然 历史 传统 木 田野 匠 古 茶 手艺 山河",
|
|
552
|
+
"launch": "活动 上新 招募 挑战 年轻 潮流 种草 福利 开箱 限量",
|
|
553
|
+
"truth": "观点 为什么 本质 真相 极简 反思 边界 定义 取舍",
|
|
554
|
+
"story": "故事 记忆 城市 深夜 旅行 纪实 情绪 时代 变迁 命运",
|
|
555
|
+
"night": "深夜 学习 效率 专注 写作 整理 笔记 书 日常 房间",
|
|
556
|
+
"epic": "史诗 爆发 冲击 巅峰 决战 荣耀 震撼 传奇 极限 突破",
|
|
557
|
+
}
|
|
558
|
+
got = {}
|
|
559
|
+
for key, text in topics.items():
|
|
560
|
+
s = {"title": text, "style": "auto",
|
|
561
|
+
"lines": [{"id": "l01", "kind": "hook", "text": text},
|
|
562
|
+
{"id": "l02", "kind": "body", "text": text}]}
|
|
563
|
+
got[key] = ST.resolve_script_style(s)["style"]
|
|
564
|
+
assert len(set(got.values())) >= 6, "8 类题材只分出 %d 套风格: %s" % (len(set(got.values())), got)
|
|
565
|
+
return "%d 套主题 + %d 类题材 -> %d 种风格" % (len(ST.STYLE_IDS), len(topics), len(set(got.values())))
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def t_style_legacy():
|
|
569
|
+
"""26 legacy 兼容:没写 style 字段必须精确落回 v0.4 的配乐/旁白参数。"""
|
|
570
|
+
import stylekit as ST
|
|
571
|
+
s = {"title": "旧项目", "lines": [{"id": "a", "kind": "body", "text": "x"}]}
|
|
572
|
+
p = ST.resolve_script_style(s)
|
|
573
|
+
assert p["legacy"] is True, "没有 style 字段却没走 legacy"
|
|
574
|
+
assert p["music"]["bpm"] == 120.0, "legacy BPM 应为 120,收到 %r" % (p["music"]["bpm"],)
|
|
575
|
+
assert p["music"]["key"] == "Dm", "legacy 调性应为 Dm,收到 %r" % (p["music"]["key"],)
|
|
576
|
+
assert p["music"]["seed_effective"] == 7, "legacy seed 应为 7(不做内容派生)"
|
|
577
|
+
assert p["music"]["seed_from_content"] is False
|
|
578
|
+
assert p["voice"]["map"] == {}, "legacy 不该有声线映射"
|
|
579
|
+
assert p["voice"]["policy"]["jitter"] == 0.0 and p["voice"]["policy"]["auto"] is False
|
|
580
|
+
# legacy 的节奏必须等于 render.py v0.4 的函数默认值,否则"没写 lead 的老剧本"会变节奏
|
|
581
|
+
assert (p["pacing"]["lead"], p["pacing"]["tail"], p["pacing"]["min_dur"]) == (0.55, 0.85, 3.0), p["pacing"]
|
|
582
|
+
# 显式 "legacy" 也必须是同一档
|
|
583
|
+
s2 = dict(s, style="legacy")
|
|
584
|
+
assert ST.resolve_script_style(s2)["legacy"] is True
|
|
585
|
+
# 显式风格则必须真的换掉三件套
|
|
586
|
+
s3 = dict(s, style="cinema")
|
|
587
|
+
p3 = ST.resolve_script_style(s3)
|
|
588
|
+
assert p3["legacy"] is False and p3["music"]["bpm"] in ST.STYLES["cinema"]["music"]["bpm_ladder"]
|
|
589
|
+
assert p3["music"]["key"] in ST.STYLES["cinema"]["music"]["keys"]
|
|
590
|
+
return "bpm=%g key=%s seed=%d / cinema: bpm=%g key=%s" % (
|
|
591
|
+
p["music"]["bpm"], p["music"]["key"], p["music"]["seed_effective"],
|
|
592
|
+
p3["music"]["bpm"], p3["music"]["key"])
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def t_style_determinism():
|
|
596
|
+
"""27 指纹确定性:同剧本同结果;改错别字不换风格;改题材才换。"""
|
|
597
|
+
import stylekit as ST
|
|
598
|
+
base = {"title": "AI 大模型的三年", "style": "auto",
|
|
599
|
+
"lines": [{"id": "l01", "kind": "hook", "text": "大模型改变了什么"},
|
|
600
|
+
{"id": "l02", "kind": "body", "text": "算法和数据的规模一起上去了"}]}
|
|
601
|
+
a = ST.resolve_script_style(base)
|
|
602
|
+
b = ST.resolve_script_style(json.loads(json.dumps(base)))
|
|
603
|
+
assert json.dumps(a, sort_keys=True, default=str) == json.dumps(b, sort_keys=True, default=str), \
|
|
604
|
+
"同一剧本两次解析结果不同(不确定性)"
|
|
605
|
+
typo = json.loads(json.dumps(base))
|
|
606
|
+
typo["lines"][1]["text"] += "。"
|
|
607
|
+
c = ST.resolve_script_style(typo)
|
|
608
|
+
assert c["style"] == a["style"], "改一个标点就换了风格: %s -> %s" % (a["style"], c["style"])
|
|
609
|
+
assert c["music"]["bpm"] == a["music"]["bpm"], "改一个标点就换了 BPM"
|
|
610
|
+
assert c["fingerprint"]["full"] != a["fingerprint"]["full"], "full 指纹没变(内容派生失效)"
|
|
611
|
+
other = {"title": "深夜学习指南", "style": "auto",
|
|
612
|
+
"lines": [{"id": "l01", "kind": "hook", "text": "为什么深夜反而能专注"},
|
|
613
|
+
{"id": "l02", "kind": "body", "text": "整理笔记,写作,阅读都更安静"}]}
|
|
614
|
+
d = ST.resolve_script_style(other)
|
|
615
|
+
assert d["style"] != a["style"], "换了一部片风格却相同: %s" % d["style"]
|
|
616
|
+
return "%s -> %s(同一剧本稳定),换片 -> %s" % (a["style"], c["style"], d["style"])
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def t_voice_plan():
|
|
620
|
+
"""28 声线计划:legacy 保持单声线;风格化时按 kind 换声线且确定性。"""
|
|
621
|
+
try:
|
|
622
|
+
import voicekit as VK
|
|
623
|
+
except Exception: # noqa: BLE001
|
|
624
|
+
return "skip"
|
|
625
|
+
import stylekit as ST
|
|
626
|
+
s = {"title": "AI 大模型", "style": "auto",
|
|
627
|
+
"lines": [{"id": "l01", "kind": "hook", "text": "大模型改变了什么"},
|
|
628
|
+
{"id": "l02", "kind": "body", "text": "算法和数据的规模一起上去了"},
|
|
629
|
+
{"id": "l03", "kind": "quote", "text": "他说:这不可能"},
|
|
630
|
+
{"id": "l04", "kind": "outro", "text": "它已经在我们身边了"}]}
|
|
631
|
+
plan = ST.resolve_script_style(s)
|
|
632
|
+
vp = VK.voice_plan(s, plan)
|
|
633
|
+
assert len(vp) == 4, "计划行数应为 4,收到 %d" % len(vp)
|
|
634
|
+
assert json.dumps(vp, sort_keys=True, default=str) == json.dumps(
|
|
635
|
+
VK.voice_plan(s, plan), sort_keys=True, default=str), "voice_plan 不确定"
|
|
636
|
+
personas = {r["persona"] for r in vp}
|
|
637
|
+
assert len(personas) >= 2, "4 句样片只用了一个声线: %s" % personas
|
|
638
|
+
tones = {r["tone"] for r in vp}
|
|
639
|
+
assert len(tones) >= 2, "语气档没有变化: %s" % tones
|
|
640
|
+
for r in vp:
|
|
641
|
+
for fld in ("voice", "rate", "pitch", "volume", "lead", "tail"):
|
|
642
|
+
assert r.get(fld) not in (None, ""), "计划行缺 %s: %r" % (fld, r.get("id"))
|
|
643
|
+
# legacy:单声线、body 语气、无抖动
|
|
644
|
+
leg = {"title": "旧项目", "lines": [{"id": "a", "kind": "body", "text": "x"}]}
|
|
645
|
+
lp = ST.resolve_script_style(leg)
|
|
646
|
+
lvp = VK.voice_plan(leg, lp)
|
|
647
|
+
assert len({r["voice"] for r in lvp}) == 1, "legacy 出现多声线"
|
|
648
|
+
assert all(r["tone"] == "body" for r in lvp), "legacy 不该换语气档"
|
|
649
|
+
return "%d 句 / %d 把声音 / %d 档语气" % (len(vp), len(personas), len(tones))
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def t_font_family():
|
|
653
|
+
"""29 字体族 + 字幕主题:换族必须换缓存键,主题字幕不能是黑底白字。
|
|
654
|
+
|
|
655
|
+
**别把"系统装了几族字体"当成产品缺陷**:CI 的 ubuntu 只装 fonts-noto-cjk,
|
|
656
|
+
四个族全回退到同一个 NotoSansCJK,宽度必然一样。v0.5.0 的第一版就在这里误报——
|
|
657
|
+
断言写成"宽度必须不同",于是 Linux CI 红、Windows 绿。
|
|
658
|
+
真正要保证的是**机制**:缓存按 family 分键(换族不会串味);
|
|
659
|
+
宽度差异只在"系统确实解析出不同字体文件"时才有意义,单族系统跳过并说明。
|
|
660
|
+
"""
|
|
661
|
+
fams = list(getattr(K, "FAMILY_IDS", ()))
|
|
662
|
+
assert fams, "filmkit 没有 FONT_FAMILIES(v0.5 字体族没生效)"
|
|
663
|
+
resolved = {}
|
|
664
|
+
for fam in fams:
|
|
665
|
+
f = K.font(40, "bold", fam)
|
|
666
|
+
assert f is not None, "字体族 %s 加载失败" % fam
|
|
667
|
+
assert f.size == 40, "字体族 %s 的字号被改写成 %s" % (fam, f.size)
|
|
668
|
+
resolved[fam] = str(getattr(f, "path", "") or "")
|
|
669
|
+
# (a) 机制保证:四个族必须是四条独立缓存,且键里带 family(否则 wrap 与绘制会用不同字体)
|
|
670
|
+
keys = [k for k in K._font_cache if len(k) == 3 and k[0] == 40 and k[1] == "bold"]
|
|
671
|
+
assert len({k[2] for k in keys}) == len(fams), \
|
|
672
|
+
"字体缓存键没带 family(换族会串味): %s" % (keys,)
|
|
673
|
+
# (b) 语义保证:仅当系统真解析出 >=2 个字体文件时,宽度才必须有差异
|
|
674
|
+
uniq_files = len(set(resolved.values()))
|
|
675
|
+
widths = {}
|
|
676
|
+
for fam in fams:
|
|
677
|
+
widths[fam] = round(K.text_w("中文测试ABC", K.font(40, "bold", fam)), 3)
|
|
678
|
+
uniq_w = len(set(widths.values()))
|
|
679
|
+
if uniq_files >= 2:
|
|
680
|
+
assert uniq_w >= 2, ("换族没换字形(%d 个字体文件却只有 %d 种宽度): %s"
|
|
681
|
+
% (uniq_files, uniq_w, widths))
|
|
682
|
+
detail = "%d 个字体族 / %d 个字体文件 / %d 种宽度" % (len(fams), uniq_files, uniq_w)
|
|
683
|
+
else:
|
|
684
|
+
# 单族回退(CI 的 fonts-noto-cjk,或极简 Linux 容器):只验机制,不验宽度
|
|
685
|
+
detail = "%d 个字体族 / 单族回退(%s)/ 只验缓存分键" % (
|
|
686
|
+
len(fams), os.path.basename(list(resolved.values())[0]) or "?")
|
|
687
|
+
# 缓存键必须区分 family(否则 wrap 与绘制会用不同字体,文字溢出安全区)
|
|
688
|
+
a = K.wrap_cn_cached("这是一句用来测试换行缓存键的话", 40, 300, "reg", "sans")
|
|
689
|
+
b = K.wrap_cn_cached("这是一句用来测试换行缓存键的话", 40, 300, "reg", "kai")
|
|
690
|
+
assert isinstance(a, list) and isinstance(b, list)
|
|
691
|
+
# 旧签名必须仍然可用(用户脚本里有大量 font(46,"reg"))
|
|
692
|
+
assert K.font(46, "reg") is not None
|
|
693
|
+
assert K.wrap_cn_cached("旧调用", 40, 500) is not None
|
|
694
|
+
# 字幕主题:浅色主题不该画黑背板
|
|
695
|
+
from PIL import Image
|
|
696
|
+
try:
|
|
697
|
+
import stylekit as ST
|
|
698
|
+
th = ST.THEMES["magazine"]
|
|
699
|
+
except Exception: # noqa: BLE001
|
|
700
|
+
th = None
|
|
701
|
+
if th is not None:
|
|
702
|
+
img = Image.new("RGB", (K.W, K.H), th["bg"])
|
|
703
|
+
K.draw_subtitle(img, "浅色主题字幕", theme=th)
|
|
704
|
+
px = img.getpixel((960, int((K.SUB_BAND[1] + K.SUB_BAND[3]) / 2)))
|
|
705
|
+
assert sum(px) > 200, "浅色主题的字幕背板是深色(%s),说明 theme 没生效" % (px,)
|
|
706
|
+
img2 = Image.new("RGB", (K.W, K.H), (0, 0, 0))
|
|
707
|
+
K.draw_subtitle(img2, "旧调用", theme=None)
|
|
708
|
+
return detail
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def t_gates():
|
|
712
|
+
"""30 门禁账本 / 签核 / QC 报告:三档状态必须能区分"没查"和"通过"。
|
|
713
|
+
|
|
714
|
+
这一条防的是 v0.6.0 自己最容易犯的错——把 skipped 混进 pass。
|
|
715
|
+
用户看到的"质检通过"必须能区分"我查了 9 项"和"我只查了 3 项":
|
|
716
|
+
前者是真知情,后者是假安全感。顺带验签核绑内容指纹:改了剧本旧签核必须失效。
|
|
717
|
+
"""
|
|
718
|
+
import tempfile
|
|
719
|
+
import gates as GT
|
|
720
|
+
w = tempfile.mkdtemp(prefix="sc_gates_")
|
|
721
|
+
lg = GT.GateLedger(w, {"title": "t", "lines": [{"text": "x"}]}, engine="pil")
|
|
722
|
+
lg.ok("A", "通过项", "1")
|
|
723
|
+
lg.fail("B", "失败项", "2")
|
|
724
|
+
lg.skip("C", "没执行项", "3")
|
|
725
|
+
c = lg.counts()
|
|
726
|
+
assert c == {"pass": 1, "fail": 1, "skipped": 1}, c
|
|
727
|
+
# 三档语义:fail 一定失败;skipped 只在 --strict-gates 下失败
|
|
728
|
+
assert len(lg.exit_errors()) == 1, "fail 没被算成失败"
|
|
729
|
+
assert len(lg.exit_errors(strict=True)) == 2, "--strict-gates 没把 skipped 升级成失败"
|
|
730
|
+
lg.write_json()
|
|
731
|
+
assert os.path.exists(os.path.join(w, "gates.json")), "gates.json 没落盘"
|
|
732
|
+
# 签核:没签 = skipped;签了 = pass;内容指纹变了 = skipped(不是 pass!)
|
|
733
|
+
ch = lg.content_hash
|
|
734
|
+
st0, _ = GT.signoff_state(w, ch)
|
|
735
|
+
assert all(v["status"] == "skipped" for v in st0.values()), st0
|
|
736
|
+
GT.write_signoff(w, "stills", ch, note="看过 sheet")
|
|
737
|
+
st1, _ = GT.signoff_state(w, ch)
|
|
738
|
+
assert st1["stills"]["status"] == "pass", st1["stills"]
|
|
739
|
+
st2, stale = GT.signoff_state(w, "别的指纹")
|
|
740
|
+
assert st2["stills"]["status"] == "skipped" and "stills" in stale, (st2, stale)
|
|
741
|
+
# 内容指纹:改剧本 / 换引擎必须变,渲染期私有键(_path/_style)不许影响
|
|
742
|
+
h1 = GT.content_hash({"title": "a", "lines": []}, engine="pil")
|
|
743
|
+
h2 = GT.content_hash({"_path": "/x", "title": "a", "lines": []}, engine="pil")
|
|
744
|
+
h3 = GT.content_hash({"title": "a", "lines": []}, engine="web")
|
|
745
|
+
assert h1 == h2, "渲染期私有键影响了内容指纹(会导致假失效)"
|
|
746
|
+
assert h1 != h3, "换引擎了内容指纹却没变(旧签核会错误地继续有效)"
|
|
747
|
+
# QC 报告:三档都要出现在矩阵里,且 skipped 不能被渲染成"通过"
|
|
748
|
+
GT.append_cost(w, "frames", 1.5, frames=30)
|
|
749
|
+
p = GT.write_report(w, os.path.join(w, "out", "QC-REPORT.md"))
|
|
750
|
+
txt = open(p, encoding="utf-8").read()
|
|
751
|
+
for must in ("门禁矩阵", "人工签核", "耗时台账", "**skipped**", "**fail**"):
|
|
752
|
+
assert must in txt, "报告里缺 %r" % must
|
|
753
|
+
assert "未通过" in txt and "通过" in txt, "报告结论段没区分通过/未通过"
|
|
754
|
+
# 原子写:不留 .tmp 尾巴
|
|
755
|
+
assert not [f for f in os.listdir(w) if f.endswith(".tmp")], os.listdir(w)
|
|
756
|
+
return "三档 1/1/1 + 签核 4 道闸 + 指纹失效 + 报告矩阵"
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def t_stagewatch():
|
|
760
|
+
"""31 阶段新鲜度:改上游 -> 下游必须报过时;首跑不刷噪声;产物丢失要报。
|
|
761
|
+
|
|
762
|
+
这一条防的是 v0.6.0 前的老问题:frames 有分块指纹,其余 stage 全是裸奔
|
|
763
|
+
(改 style 后 audio 仍读旧 music.wav、改 hold 后 frames 仍用旧 cues.json,都不报错)。
|
|
764
|
+
"""
|
|
765
|
+
import tempfile
|
|
766
|
+
import stagewatch as SW
|
|
767
|
+
w = tempfile.mkdtemp(prefix="sc_sw_")
|
|
768
|
+
assert all(not r["issues"] for r in SW.explain(w)), "空目录不该报过时"
|
|
769
|
+
|
|
770
|
+
def _wf(name, text):
|
|
771
|
+
p = os.path.join(w, name)
|
|
772
|
+
d = os.path.dirname(p)
|
|
773
|
+
if d:
|
|
774
|
+
os.makedirs(d, exist_ok=True)
|
|
775
|
+
open(p, "w", encoding="utf-8").write(text)
|
|
776
|
+
|
|
777
|
+
_wf("style.json", "s1")
|
|
778
|
+
_wf("voice_plan.json", "v1")
|
|
779
|
+
SW.snapshot(w, "style")
|
|
780
|
+
assert not [i for r in SW.explain(w, ["style"]) for i in r["issues"]], "刚跑完就报过时"
|
|
781
|
+
_wf("tts.json", "t1")
|
|
782
|
+
SW.snapshot(w, "tts")
|
|
783
|
+
# 改 tts 的上游(style.json)-> tts 必须报 input-changed
|
|
784
|
+
_wf("style.json", "s2")
|
|
785
|
+
issues = [i for r in SW.explain(w, ["tts"]) for i in r["issues"]]
|
|
786
|
+
assert any(i["kind"] == "input-changed" for i in issues), issues
|
|
787
|
+
assert any("render.py" in (i.get("rerun") or "") for i in issues), "过时了却没给重跑命令"
|
|
788
|
+
# 产物被删 -> 必须报 output-missing
|
|
789
|
+
os.remove(os.path.join(w, "tts.json"))
|
|
790
|
+
issues = [i for r in SW.explain(w, ["tts"]) for i in r["issues"]]
|
|
791
|
+
assert any(i["kind"] == "output-missing" for i in issues), issues
|
|
792
|
+
# 默认只告警;--strict-stale 才失败
|
|
793
|
+
errs, warns = SW.report(w, ["tts"], write=lambda lv, m: None)
|
|
794
|
+
assert not errs and warns, (errs, warns)
|
|
795
|
+
errs_strict, _ = SW.report(w, ["tts"], strict=True, write=lambda lv, m: None)
|
|
796
|
+
assert errs_strict, "--strict-stale 没有让过时变成失败"
|
|
797
|
+
# **源头输入**(剧本 / scenes.py)改了但没重跑 -> 必须报 source-changed。
|
|
798
|
+
# 这条是 v0.6.0 补的洞:早期只跟踪 stage 之间的产物,"改了台词没重跑 tts" 查不出来,
|
|
799
|
+
# 而那正是最需要报警的场景(旧 tts 配新剧本 = 配音字幕全错位)。
|
|
800
|
+
w2 = tempfile.mkdtemp(prefix="sc_sw2_")
|
|
801
|
+
film = os.path.join(w2, "film.json")
|
|
802
|
+
scenes = os.path.join(w2, "scenes.py")
|
|
803
|
+
open(film, "w", encoding="utf-8").write("{\"title\":\"a\"}")
|
|
804
|
+
open(scenes, "w", encoding="utf-8").write("# v1")
|
|
805
|
+
src = {"script": film, "scenes": scenes}
|
|
806
|
+
_wf2 = lambda n, t: open(os.path.join(w2, n), "w", encoding="utf-8").write(t)
|
|
807
|
+
_wf2("tts.json", "t1")
|
|
808
|
+
SW.snapshot(w2, "tts", src)
|
|
809
|
+
issues = [i for r in SW.explain(w2, ["tts"], src) for i in r["issues"]]
|
|
810
|
+
assert not issues, "刚跑完就报过时:%s" % issues
|
|
811
|
+
open(film, "w", encoding="utf-8").write("{\"title\":\"b\"}") # 改剧本
|
|
812
|
+
issues = [i for r in SW.explain(w2, ["tts"], src) for i in r["issues"]]
|
|
813
|
+
assert any(i["kind"] == "source-changed" for i in issues), (
|
|
814
|
+
"改了剧本却没报过时(这正是 v0.6.0 要堵的洞):%s" % issues)
|
|
815
|
+
assert any("film.json" in i["reason"] for i in issues), issues
|
|
816
|
+
# 坏 manifest 不许崩(用户手改坏一个 json 不该让整片跑不动)
|
|
817
|
+
open(os.path.join(w, "manifest.json"), "w", encoding="utf-8").write("{broken")
|
|
818
|
+
SW.explain(w)
|
|
819
|
+
return "改上游/改源头即报过时 + 产物丢失 + strict 升级 + 坏 manifest 不崩"
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def t_parallel_frames():
|
|
823
|
+
"""32 --jobs >1 的并行路径能真的跑(v0.6.0 修的 pre-existing bug)。
|
|
824
|
+
|
|
825
|
+
bug 形状:worker 从 _script_snapshot.json 读剧本,JSON 把主题色元组变成 list,
|
|
826
|
+
scenes._bg() 拿它当缓存键 -> TypeError: unhashable type: "list",
|
|
827
|
+
于是 SKILL 里教的 --stage frames --jobs 4 直接崩。
|
|
828
|
+
这里**不跑整片**(太慢),只验两条防线:① 主题色能过 JSON 往返当缓存键;
|
|
829
|
+
② worker 反序列化后会重解风格(_style 里的颜色回到 tuple)。
|
|
830
|
+
"""
|
|
831
|
+
import json as _json
|
|
832
|
+
import tempfile
|
|
833
|
+
import stylekit as ST
|
|
834
|
+
th = dict(ST.THEMES["tech-deep"])
|
|
835
|
+
# ① 防御层:scenes._hashable 把 list 冻结成 tuple
|
|
836
|
+
scenes = _load_scenes_mod()
|
|
837
|
+
if scenes is not None and hasattr(scenes, "_hashable"):
|
|
838
|
+
frozen = scenes._hashable(("baked", th["bg"], th["bg2"]))
|
|
839
|
+
hash(frozen) # 不能抛 unhashable
|
|
840
|
+
assert isinstance(frozen[1], tuple), "主题色没被冻结成 tuple:%r" % (frozen[1],)
|
|
841
|
+
# ② 根因层:JSON 往返后,_render_chunk_worker 里必须能拿到 tuple 的 bg
|
|
842
|
+
round_trip = _json.loads(_json.dumps({"bg": th["bg"]}, ensure_ascii=False))
|
|
843
|
+
assert isinstance(round_trip["bg"], list), "前提变了:JSON 居然保住了 tuple"
|
|
844
|
+
w = tempfile.mkdtemp(prefix="sc_par_")
|
|
845
|
+
snap = os.path.join(w, "snap.json")
|
|
846
|
+
script = {"title": "t", "style": "tech-deep", "lines": [{"text": "x"}]}
|
|
847
|
+
script["_style"] = ST.resolve_script_style(script)
|
|
848
|
+
with open(snap, "w", encoding="utf-8") as fh:
|
|
849
|
+
_json.dump(script, fh, ensure_ascii=False)
|
|
850
|
+
back = _json.load(open(snap, encoding="utf-8"))
|
|
851
|
+
assert isinstance(back["_style"]["theme"]["bg"], list), "前提变了"
|
|
852
|
+
# 重解风格(render.resolve_style 的纯函数部分):必须回到 tuple
|
|
853
|
+
again = ST.resolve_script_style(back)
|
|
854
|
+
assert isinstance(again["theme"]["bg"], tuple), (
|
|
855
|
+
"--jobs >1 时主题色仍是 list,_bg() 会 TypeError(这正是被修的 bug):%r"
|
|
856
|
+
% (again["theme"]["bg"],))
|
|
857
|
+
return "主题标量可哈希 + 反序列化后重解回 tuple"
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _load_scenes_mod():
|
|
861
|
+
"""加载 assets/scenes.py 模板(找不到就返回 None:CI 里可能只拷 scripts/)。"""
|
|
862
|
+
import importlib.util
|
|
863
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
864
|
+
cand = os.path.join(here, "..", "assets", "scenes.py")
|
|
865
|
+
if not os.path.exists(cand):
|
|
866
|
+
return None
|
|
867
|
+
try:
|
|
868
|
+
spec = importlib.util.spec_from_file_location("sc_assets_scenes", cand)
|
|
869
|
+
mod = importlib.util.module_from_spec(spec)
|
|
870
|
+
spec.loader.exec_module(mod)
|
|
871
|
+
return mod
|
|
872
|
+
except Exception: # noqa: BLE001
|
|
873
|
+
return None
|
|
874
|
+
|
|
875
|
+
def t_sfx_content_seed():
|
|
876
|
+
"""v0.7:音效 seed 必须随内容变化,但 legacy 路径必须逐样本不变。
|
|
877
|
+
|
|
878
|
+
- 不传 style/content_fp -> 两次渲染逐样本相同(v0.4 兼容,不破老片);
|
|
879
|
+
- 传了不同 content_fp -> 必须不同(否则跨片音效还是"都一样");
|
|
880
|
+
- 同一 content_fp -> 逐样本相同(可复现,是程序化视频的底线)。
|
|
881
|
+
"""
|
|
882
|
+
import numpy as np
|
|
883
|
+
scenes = [{"id": "l01", "kind": "hook", "start": 0.0, "end": 2.0},
|
|
884
|
+
{"id": "l02", "kind": "body", "start": 2.0, "end": 6.0}]
|
|
885
|
+
rows = [{"at": "l02", "type": "ui_click"}, {"at": "l02+0.5", "type": "whoosh"}]
|
|
886
|
+
|
|
887
|
+
def build(**kw):
|
|
888
|
+
b = K.CueBus(scenes=scenes, total=6.0)
|
|
889
|
+
b.extend(rows)
|
|
890
|
+
return b.build_audio(total=6.0, verbose=False, **kw)
|
|
891
|
+
|
|
892
|
+
try:
|
|
893
|
+
legacy_a, legacy_b = build(), build()
|
|
894
|
+
except Exception: # noqa: BLE001
|
|
895
|
+
return "skip" # sfxkit 不可用
|
|
896
|
+
assert np.array_equal(legacy_a, legacy_b), "legacy 两次渲染不一致(v0.4 兼容被破坏)"
|
|
897
|
+
a = build(style="tech-deep", content_fp="aaaa1111", key="Dm")
|
|
898
|
+
b = build(style="tech-deep", content_fp="bbbb2222", key="Dm")
|
|
899
|
+
assert not np.array_equal(a, b), "不同内容指纹得到相同音效轨:跨片音效仍一模一样"
|
|
900
|
+
a2 = build(style="tech-deep", content_fp="aaaa1111", key="Dm")
|
|
901
|
+
assert np.array_equal(a, a2), "同内容指纹渲染不可复现"
|
|
902
|
+
return "legacy 逐样本相同 / 跨片可区分"
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def t_audio_separation():
|
|
906
|
+
"""v0.7:混音侧 duck 默认必须加深(片内配乐给音效让位)。
|
|
907
|
+
|
|
908
|
+
直接读 render.py 的常量文本而不是跑全链:这条断言盯的是"默认值没被改回去"
|
|
909
|
+
(v0.4 的 3.0 dB 在段落过门时配乐几乎和音效等高,是"听起来像同一套"的成因之一)。
|
|
910
|
+
"""
|
|
911
|
+
import re
|
|
912
|
+
path = os.path.join(HERE, "render.py")
|
|
913
|
+
src = open(path, encoding="utf-8").read()
|
|
914
|
+
m = re.search(r"duck_default\s*=\s*([0-9.]+)", src)
|
|
915
|
+
assert m, "render.py 里找不到 duck_default"
|
|
916
|
+
base = float(m.group(1))
|
|
917
|
+
assert base >= 3.5, "duck_default=%.1f dB 太浅(v0.7 要求 >= 3.5)" % base
|
|
918
|
+
for sid, want in (("mono", 3.0), ("cinema", 5.5), ("epic", 5.5)):
|
|
919
|
+
mm = re.search(r'"%s"\s*:\s*([0-9.]+)' % sid, src)
|
|
920
|
+
assert mm, "render.py 里找不到风格 %s 的 duck 值" % sid
|
|
921
|
+
assert float(mm.group(1)) >= want, "%s duck=%.1f < %.1f" % (sid, float(mm.group(1)), want)
|
|
922
|
+
return "duck 默认 %.1f dB(cinema/epic 5.5)" % base
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def t_voice_priority():
|
|
926
|
+
"""v0.7.1 旁白优先:人声出现时背景必须再压,人声间隙背景恢复。
|
|
927
|
+
|
|
928
|
+
以前配乐基线只比旁白低 1.5 dB,视频以旁白为主时观众要费力分辨字。
|
|
929
|
+
修法:`mix_film` 用旁白包络做侧链——人声处配乐 -6 dB、音效 -3 dB,间隙回原电平。
|
|
930
|
+
这里分别验证「人声段被压」和「静音段恢复」,以及旁白自身电平不被影响。
|
|
931
|
+
"""
|
|
932
|
+
import numpy as np
|
|
933
|
+
n = int(2.0 * K.SR)
|
|
934
|
+
t = np.arange(n) / float(K.SR)
|
|
935
|
+
voice = np.zeros(n, dtype=np.float32)
|
|
936
|
+
voice[: n // 2] = (0.2 * np.sin(2 * np.pi * 220 * t[: n // 2])).astype(np.float32)
|
|
937
|
+
music = np.full((n, 2), 0.2, dtype=np.float32)
|
|
938
|
+
out = K.mix_film(voice, music, None, total=2.0, fade=0.0,
|
|
939
|
+
music_gain_db=0.0, voice_duck_db=6.0, guard_snr_db=0.0)
|
|
940
|
+
v = np.stack([voice, voice], axis=1)
|
|
941
|
+
music_part = out - v # 混音是线性叠加,减掉人声即背景分量
|
|
942
|
+
# 人声段:取中段(避开包络起音/释放的斜坡)
|
|
943
|
+
talk = music_part[int(0.35 * K.SR): int(0.45 * K.SR)]
|
|
944
|
+
gap = music_part[int(1.55 * K.SR): int(1.95 * K.SR)]
|
|
945
|
+
talk_rms = float(np.sqrt(np.mean(talk * talk)))
|
|
946
|
+
gap_rms = float(np.sqrt(np.mean(gap * gap)))
|
|
947
|
+
# 期望:人声段 ≈ 0.2*10^(-6/20)=0.100;间隙 ≈ 0.2
|
|
948
|
+
assert abs(talk_rms - 0.1003) < 0.03, "人声段背景没被压到 -6 dB(RMS %.3f,期望 0.100)" % talk_rms
|
|
949
|
+
assert abs(gap_rms - 0.2) < 0.03, "人声间隙背景没恢复(RMS %.3f,期望 0.200)" % gap_rms
|
|
950
|
+
# SNR 口径:只统计人声活跃区(含间隙会把问题稀释掉)。
|
|
951
|
+
# 这里配乐基线是 0 dB,只有 -6 dB 侧链 -> SNR 应为 -3+6 = 3 dB 左右。
|
|
952
|
+
snr = K.voice_snr(voice, music_part)
|
|
953
|
+
assert snr is not None and 2.0 <= snr <= 4.0, "voice_snr 口径异常(%.1f dB,期望约 3)" % (snr or -99)
|
|
954
|
+
# 旁白自身电平不能被侧链动过
|
|
955
|
+
voice_part = out[:, 0] - music_part[:, 0]
|
|
956
|
+
seg = voice_part[int(0.35 * K.SR): int(0.45 * K.SR)]
|
|
957
|
+
want = voice[int(0.35 * K.SR): int(0.45 * K.SR)]
|
|
958
|
+
assert float(np.max(np.abs(seg - want))) < 1e-4, "旁白电平被侧链改变(旁白优先不能压人声)"
|
|
959
|
+
return "人声段背景 %.3f / 间隙 %.3f(dB 差 %.1f)" % (
|
|
960
|
+
talk_rms, gap_rms, 20 * np.log10(gap_rms / (talk_rms + 1e-9)))
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def t_music_gain_default():
|
|
964
|
+
"""v0.7.1:配乐默认增益必须是 -12 dB(beside 人声),不是旧版的 -1.5。
|
|
965
|
+
|
|
966
|
+
这条盯的是 `render.st_audio` 的默认值:`mus.get("gain_db", -12.0)`。
|
|
967
|
+
直接读源码文本,避免"跑全链才知道默认值"。
|
|
968
|
+
"""
|
|
969
|
+
src = open(os.path.join(HERE, "render.py"), encoding="utf-8").read()
|
|
970
|
+
assert 'gain_db", -12.0' in src, "st_audio 的配乐默认增益不是 -12 dB"
|
|
971
|
+
assert 'voice_duck_db", 6.0' in src, "st_audio 没有开旁白侧链(voice_duck_db=6)"
|
|
972
|
+
return "配乐 -12 dB + 旁白侧链 6 dB"
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def t_snr_guard():
|
|
976
|
+
"""v0.7.1 SNR 兜底:背景天然太响时,混音后必须自己把它压到"人声听得清"。
|
|
977
|
+
|
|
978
|
+
构造一段人声 + 一段刻意很响的配乐(比人声高 10 dB),不设侧链、只靠 guard:
|
|
979
|
+
`mix_film(guard_snr_db=12)` 后实测人声活跃区的 人声/背景 必须 >= 12 dB,
|
|
980
|
+
且人声本身电平不能被兜底改过(兜底只压背景)。
|
|
981
|
+
"""
|
|
982
|
+
import numpy as np
|
|
983
|
+
sr = K.SR
|
|
984
|
+
n = int(3.0 * sr)
|
|
985
|
+
t = np.arange(n) / float(sr)
|
|
986
|
+
voice = np.zeros(n, dtype=np.float32)
|
|
987
|
+
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)
|
|
988
|
+
# 配乐 RMS 约 0.6,比人声(RMS 0.141)高 ~12.5 dB —— 刻意做成"盖住旁白"的反例
|
|
989
|
+
loud = np.full((n, 2), 0.6, dtype=np.float32)
|
|
990
|
+
out = K.mix_film(voice, loud, None, total=3.0, fade=0.0,
|
|
991
|
+
music_gain_db=0.0, voice_duck_db=0.0, voice_duck_sfx_db=0.0,
|
|
992
|
+
guard_snr_db=12.0, guard_cap_db=30.0, verbose=False)
|
|
993
|
+
v2 = np.stack([voice, voice], axis=1)
|
|
994
|
+
bg = out - v2
|
|
995
|
+
snr = K.voice_snr(voice, bg)
|
|
996
|
+
assert snr is not None and snr >= 11.9, "SNR 兜底没生效:人声/背景 %.1f dB(要求 >=12)" % (snr or -99)
|
|
997
|
+
# 人声电平不能被兜底动过
|
|
998
|
+
seg_v = (out[:, 0] - bg[:, 0])[int(1.0*sr):int(2.0*sr)]
|
|
999
|
+
want = voice[int(1.0*sr):int(2.0*sr)]
|
|
1000
|
+
assert float(np.max(np.abs(seg_v - want))) < 1e-4, "SNR 兜底改到了人声电平"
|
|
1001
|
+
return "兜底前约 -12.5 dB -> 兜底后 %.1f dB" % snr
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def t_mix_no_voice():
|
|
1005
|
+
"""没有旁白时混音行为不变:没有 voice_env/guard 可用,背景应原样输出。"""
|
|
1006
|
+
import numpy as np
|
|
1007
|
+
n = int(0.5 * K.SR)
|
|
1008
|
+
music = np.full((n, 2), 0.2, dtype=np.float32)
|
|
1009
|
+
out = K.mix_film(None, music, None, total=0.5, fade=0.0, music_gain_db=0.0, verbose=False)
|
|
1010
|
+
assert abs(float(np.max(np.abs(out))) - 0.2) < 1e-5, "无旁白时配乐被改动了(峰值 %.3f)" % float(np.max(np.abs(out)))
|
|
1011
|
+
return "无旁白时背景不被压"
|
|
1012
|
+
|
|
1013
|
+
# ------------------------------------------------------------------ v0.7.2 新增(全项目深审)
|
|
1014
|
+
def t_audio_edge_inputs():
|
|
1015
|
+
"""39 音频入口的边界:极短旁白不能崩、NaN/Inf 必须拒绝、seg=0 不许死循环。
|
|
1016
|
+
|
|
1017
|
+
这几条都是"真跑一次就会遇到"的输入。v0.7.1 及以前的行为:
|
|
1018
|
+
* voice_env 在极短音频上 np.convolve(mode="same") 返回核长 -> np.maximum 广播失败;
|
|
1019
|
+
* duck_voice 在"旁白比背景短"时包络与背景长度不同 -> 广播失败;
|
|
1020
|
+
* wav_write 对 NaN 做 clip 再 astype(int16),把 NaN 静默写成 0(写出损坏 WAV 却不报错);
|
|
1021
|
+
* AudioBus.place 传负起点时负下标切片 -> n=0 -> 广播崩;
|
|
1022
|
+
* music_bed 的 seg=0 让 t 不前进 -> while 死循环(有 100000 次上限,6 秒片要 2 分钟)。
|
|
1023
|
+
"""
|
|
1024
|
+
import numpy as np
|
|
1025
|
+
short = np.full(600, 0.2, dtype=np.float32) # 12.5 ms
|
|
1026
|
+
assert len(K.voice_env(short)) >= 1, "voice_env 对极短音频返回空包络"
|
|
1027
|
+
bg = np.full((K.SR, 2), 0.2, dtype=np.float32)
|
|
1028
|
+
got = K.duck_voice(bg, short, depth_db=6.0)
|
|
1029
|
+
assert got.shape == (K.SR, 2), "duck_voice 在旁白比背景短时形状不对: %r" % (got.shape,)
|
|
1030
|
+
m = np.full((600, 2), 0.2, dtype=np.float32)
|
|
1031
|
+
assert K.duck(m, short).shape == (600, 2), "duck 在短信号上形状不对"
|
|
1032
|
+
|
|
1033
|
+
with tempfile.TemporaryDirectory() as d:
|
|
1034
|
+
for name, bad in (("nan", np.float32("nan")), ("inf", np.float32("inf"))):
|
|
1035
|
+
p = os.path.join(d, name + ".wav")
|
|
1036
|
+
try:
|
|
1037
|
+
K.wav_write(p, np.array([0.1, bad], dtype=np.float32))
|
|
1038
|
+
except ValueError:
|
|
1039
|
+
assert not os.path.exists(p), "wav_write 报错了却仍写出文件(%s)" % p
|
|
1040
|
+
else:
|
|
1041
|
+
raise AssertionError("wav_write 把 %s 静默写进了 WAV(应当报错)" % name)
|
|
1042
|
+
|
|
1043
|
+
bus = K.AudioBus(2, K.SR, 1.0)
|
|
1044
|
+
bus.place(np.ones(1000, dtype=np.float32), -0.25) # 负起点:从 0 秒开始,不许崩
|
|
1045
|
+
bus2 = K.AudioBus(2, K.SR, 0.3)
|
|
1046
|
+
try:
|
|
1047
|
+
bus2.music_bed([[220.0, 277.0, 330.0]], seg=0.0)
|
|
1048
|
+
except ValueError:
|
|
1049
|
+
pass
|
|
1050
|
+
else:
|
|
1051
|
+
raise AssertionError("music_bed(seg=0) 没被拒绝(会死循环)")
|
|
1052
|
+
return "极短旁白不崩 / NaN·Inf 拒绝 / 负起点与 seg=0 有守卫"
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def t_report_single_source():
|
|
1056
|
+
"""40 QC 报告落点只能有一个来源:--out 的目录。
|
|
1057
|
+
|
|
1058
|
+
v0.7.2 修的真 bug:all/mux/qa 用 dirname(abspath(out)),accept 用
|
|
1059
|
+
dirname(abspath(work))/out。--out 指到 deliver/ 时,签核更新的是另一份没人
|
|
1060
|
+
交付的文件,交付出去的那份仍是"未签核"——正好打穿"报告是交付凭据"这条承诺。
|
|
1061
|
+
"""
|
|
1062
|
+
import types
|
|
1063
|
+
try:
|
|
1064
|
+
import render
|
|
1065
|
+
except Exception as e: # noqa: BLE001
|
|
1066
|
+
return "skip(import render 失败:%s)" % e
|
|
1067
|
+
assert hasattr(render, "_report_path"), "render 没有统一的 _report_path"
|
|
1068
|
+
a = types.SimpleNamespace(out=os.path.join("deliver", "film.mp4"), work="work")
|
|
1069
|
+
got = render._report_path(a)
|
|
1070
|
+
want = os.path.join("deliver", "QC-REPORT.md")
|
|
1071
|
+
assert os.path.abspath(got) == os.path.abspath(want), \
|
|
1072
|
+
"报告没落在 --out 旁边:%s(期望 %s)" % (got, want)
|
|
1073
|
+
src = open(os.path.join(HERE, "render.py"), encoding="utf-8").read()
|
|
1074
|
+
n = src.count('"QC-REPORT.md"')
|
|
1075
|
+
assert n == 1, "QC-REPORT.md 路径出现了 %d 处(应只有 _report_path 一处,否则又会两边漂移)" % n
|
|
1076
|
+
return "报告落点统一到 --out 目录(源码里只有一处拼接)"
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def t_empty_ledger_not_pass():
|
|
1080
|
+
"""41 门禁账本为空时,QC 报告不许说"通过"。
|
|
1081
|
+
|
|
1082
|
+
render_report 原来 if counts.get(FAIL): ... else: **通过**——gates.json 被删、
|
|
1083
|
+
写坏、或根本没跑 qa 时,交付凭据会是一份宣称通过的纸。这条盯的就是那个 else。
|
|
1084
|
+
"""
|
|
1085
|
+
try:
|
|
1086
|
+
import gates as GT
|
|
1087
|
+
except Exception as e: # noqa: BLE001
|
|
1088
|
+
return "skip(没有 gates.py:%s)" % e
|
|
1089
|
+
with tempfile.TemporaryDirectory() as d:
|
|
1090
|
+
p = GT.write_report(d, os.path.join(d, "QC-REPORT.md"))
|
|
1091
|
+
txt = open(p, encoding="utf-8").read()
|
|
1092
|
+
assert "**通过**" not in txt, "空门禁账本却写成『通过』(交付凭据撒谎)"
|
|
1093
|
+
assert "未判定" in txt, "空门禁账本没写『未判定』(用户会以为跑过了)"
|
|
1094
|
+
return "0 条门禁 -> 未判定(不是通过)"
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def t_stage_io_no_phantom_output():
|
|
1098
|
+
"""42 单阶段连跑不许报"自己的产物不见了"(假 stale)。
|
|
1099
|
+
|
|
1100
|
+
v0.7.2 修:stagewatch 的 STAGE_IO[style].outputs 里把 voice_plan.json 也算成
|
|
1101
|
+
style 的产物,而它其实是 tts 阶段写的(render.py 的 st_tts)。于是 --stage style
|
|
1102
|
+
连跑两次,第二次必然报"产物 voice_plan.json 不见了";打开 --strict-stale 就变成
|
|
1103
|
+
硬失败——SKILL.md 第 3 步(全新项目第一条生产命令)自己走不通。
|
|
1104
|
+
"""
|
|
1105
|
+
try:
|
|
1106
|
+
import stagewatch as SW
|
|
1107
|
+
except Exception as e: # noqa: BLE001
|
|
1108
|
+
return "skip(没有 stagewatch.py:%s)" % e
|
|
1109
|
+
style_out = list((SW.STAGE_IO.get("style") or {}).get("outputs") or [])
|
|
1110
|
+
tts_out = list((SW.STAGE_IO.get("tts") or {}).get("outputs") or [])
|
|
1111
|
+
if "voice_plan.json" in style_out:
|
|
1112
|
+
assert "voice_plan.json" in tts_out, \
|
|
1113
|
+
"voice_plan.json 被算成 style 的产物,但没有别的 stage 认领它(假 stale 会回来)"
|
|
1114
|
+
dup = [(n, st) for st, io in SW.STAGE_IO.items() for n in (io.get("outputs") or [])
|
|
1115
|
+
if n in style_out and st != "style"]
|
|
1116
|
+
assert not dup, \
|
|
1117
|
+
"style 的产物被别的 stage 也声明成输出(会产生假 stale):%r" % (dup,)
|
|
1118
|
+
return "style 产物 %s / tts 产物 %s" % (",".join(sorted(style_out)), ",".join(sorted(tts_out)))
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def t_theme_overrides_linted():
|
|
1122
|
+
"""43 theme_overrides 必须参与主题体检(否则用户能造出"看不清"的成片)。
|
|
1123
|
+
|
|
1124
|
+
v0.7.2 修的真 bug:render.resolve_style 以前只体检**基础主题**
|
|
1125
|
+
(ST.THEMES[theme_id]),而 scenes 实际用的是 plan[theme](基础主题 + film.json 的
|
|
1126
|
+
visual.theme_overrides)。于是把 ink/bg 调成低对比度时体检照样 PASS,渲完才发现字幕看不清。
|
|
1127
|
+
实测 overrides ink=[30,30,30]/bg=[10,10,10]:基础主题 lint 为空,最终主题 lint 报对比度 1.09。
|
|
1128
|
+
"""
|
|
1129
|
+
import stylekit as ST
|
|
1130
|
+
base = {'title': 'x', 'style': 'auto',
|
|
1131
|
+
'lines': [{'id': 'l01', 'kind': 'body', 'text': 'y'}]}
|
|
1132
|
+
p = ST.resolve_script_style(dict(base, visual={'theme_overrides': {
|
|
1133
|
+
'ink': [30, 30, 30], 'bg': [10, 10, 10]}}))
|
|
1134
|
+
assert ST.theme_lint(ST.THEMES[p['theme_id']]) == [], \
|
|
1135
|
+
"前提变了:基础主题本身就有 lint 问题,这条断言失去意义"
|
|
1136
|
+
bad = ST.theme_lint(p['theme'])
|
|
1137
|
+
assert bad, '低对比度的 theme_overrides 没被 lint 抓到(用户能造出看不清的字幕)'
|
|
1138
|
+
src = open(os.path.join(HERE, "render.py"), encoding="utf-8").read()
|
|
1139
|
+
assert "ST.theme_lint(plan.get('theme')" in src or 'ST.theme_lint(plan.get("theme")' in src, \
|
|
1140
|
+
"render.resolve_style 仍在体检基础主题而不是最终主题"
|
|
1141
|
+
return "最终主题参与体检(%s)" % ";".join(bad)[:60]
|
|
1142
|
+
|
|
1143
|
+
|
|
517
1144
|
CASES = [
|
|
518
1145
|
("1 TTS 缓存校验文本", t_cache_key),
|
|
519
1146
|
("2 sub_window 覆盖长句", t_sub_window),
|
|
@@ -539,6 +1166,25 @@ CASES = [
|
|
|
539
1166
|
("22 webengine 页面契约", t_webengine),
|
|
540
1167
|
("23 qa_frames 函数齐全", t_qa_frames),
|
|
541
1168
|
("24 配乐来源判定(off 不读旧配乐)", t_music_plan),
|
|
1169
|
+
("25 风格层:8 套主题过对比度", t_style_kits),
|
|
1170
|
+
("26 风格 legacy 兼容", t_style_legacy),
|
|
1171
|
+
("27 内容指纹确定性", t_style_determinism),
|
|
1172
|
+
("28 声线计划", t_voice_plan),
|
|
1173
|
+
("29 字体族与主题字幕", t_font_family),
|
|
1174
|
+
("30 门禁账本与签核", t_gates),
|
|
1175
|
+
("31 阶段新鲜度守卫", t_stagewatch),
|
|
1176
|
+
("32 并行渲染主题序列化", t_parallel_frames),
|
|
1177
|
+
("33 音效跨片可区分(v0.7 内容派生 seed)", t_sfx_content_seed),
|
|
1178
|
+
("34 配乐/音效片内分离(v0.7 duck 加深)", t_audio_separation),
|
|
1179
|
+
("35 旁白优先(v0.7.1 背景让位与恢复)", t_voice_priority),
|
|
1180
|
+
("36 旁白优先默认值(配乐 -12 dB + 侧链)", t_music_gain_default),
|
|
1181
|
+
("37 旁白清晰度 SNR 兜底(背景太响时自动补压)", t_snr_guard),
|
|
1182
|
+
("38 无旁白时混音不受影响", t_mix_no_voice),
|
|
1183
|
+
("39 音频边界:极短/NaN·Inf/负起点/seg=0", t_audio_edge_inputs),
|
|
1184
|
+
("40 QC 报告落点只有一个来源", t_report_single_source),
|
|
1185
|
+
("41 空门禁账本不许写成通过", t_empty_ledger_not_pass),
|
|
1186
|
+
("42 单阶段产物归属(防假 stale)", t_stage_io_no_phantom_output),
|
|
1187
|
+
("43 theme_overrides 参与主题体检", t_theme_overrides_linted),
|
|
542
1188
|
]
|
|
543
1189
|
|
|
544
1190
|
|