@dsh-gis/dsh-video-studio-skill 0.8.1 → 1.0.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/CHANGELOG.md +582 -0
- package/README.md +287 -304
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/package.json +11 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +171 -15
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1316 -1066
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +1 -2
- package/skills/video-studio/assets/golden_frames.json +12 -9
- package/skills/video-studio/assets/scenes.py +2207 -1190
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/audio-variation.md +15 -11
- package/skills/video-studio/references/audio.md +36 -34
- package/skills/video-studio/references/change-impact.md +3 -3
- package/skills/video-studio/references/frame-qa.md +2 -2
- package/skills/video-studio/references/proposal.md +177 -0
- package/skills/video-studio/references/quality-gates.md +2 -2
- package/skills/video-studio/references/scene-grammar.md +311 -0
- package/skills/video-studio/references/sfx-design.md +1 -1
- package/skills/video-studio/references/style-packs.md +107 -168
- package/skills/video-studio/references/v08-quality.md +8 -8
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/references/word-timeline.md +2 -2
- package/skills/video-studio/scripts/designkit.py +2120 -0
- package/skills/video-studio/scripts/filmcheck.py +104 -33
- package/skills/video-studio/scripts/filmkit.py +153 -78
- package/skills/video-studio/scripts/grammar.py +619 -0
- package/skills/video-studio/scripts/musicgen.py +2043 -0
- package/skills/video-studio/scripts/preflight.py +10 -3
- package/skills/video-studio/scripts/previewkit.py +849 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +45 -10
- package/skills/video-studio/scripts/qavmaf.py +77 -11
- package/skills/video-studio/scripts/render.py +505 -142
- package/skills/video-studio/scripts/scorekit.py +50 -1748
- package/skills/video-studio/scripts/selfcheck.py +851 -115
- package/skills/video-studio/scripts/stagewatch.py +21 -5
- package/skills/video-studio/scripts/stylekit.py +293 -570
- package/skills/video-studio/scripts/voicekit.py +502 -380
- package/skills/video-studio/scripts/webengine.py +15 -14
- package/tools/audio_similarity.py +57 -30
- package/tools/ci_check.py +9 -1
- package/tools/estimate.py +14 -8
- package/tools/golden_frames.py +56 -11
- package/tools/silhouette_qa.py +1263 -0
- package/docs/demo-pil-sheet.png +0 -0
- package/docs/styles/demo-results.json +0 -76
- 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/skills/video-studio/assets/score_demo.json +0 -307
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
23 (v0.3)qa_frames 可用时:能检出注入的单帧闪烁
|
|
30
30
|
24 (v0.4.0)配乐来源:music.mode=off 不读上一轮 work/music.wav(模式判定回归)
|
|
31
31
|
25 (v0.5.0)风格层:8 套主题全部过对比度门禁,8 类题材能分出 >=6 种风格
|
|
32
|
-
26 (
|
|
32
|
+
26 (v1.0.0)legacy 已移除:不写 style 必须**报错并给迁移指引**(v0.5 时代这条断言
|
|
33
|
+
的是"精确落回 Dm/120/seed7/单声线",D3 把那条路径删了,断言随之反转)
|
|
33
34
|
27 (v0.5.0)内容指纹:同剧本稳定、改标点不换风格、换题材才换
|
|
34
35
|
28 (v0.5.0)声线计划:4 句样片 >=2 把声音,legacy 仍是单声线
|
|
35
36
|
29 (v0.5.0)字体族与主题字幕:换族换缓存键,浅色主题不画黑背板
|
|
@@ -55,9 +56,24 @@
|
|
|
55
56
|
49 (v0.8.0)TTS 后端可替换:注册/注销/未知后端的接口契约
|
|
56
57
|
50 (v0.8.0)web 风格载荷:style=None 时不得注入 __STYLE__(legacy 必须回落)
|
|
57
58
|
51 (v0.8.0)金帧回归:与 assets/golden_frames.json 的像素基线比对
|
|
59
|
+
52 (v0.9.0)场景语法:10 类构图分流、选型确定性、段长降级门
|
|
60
|
+
53 (v0.9.0)视觉语言包:8 套结构签名齐全、过体检、彼此不是同一套
|
|
61
|
+
54 (v0.9.0)场景类型单真值:grammar / filmcheck / scenes / schema 四处集合相等
|
|
62
|
+
55 (v0.9.0)剪影可区分(G13):8 套风格两两之间构图骨架必须能区分
|
|
63
|
+
56 (v1.0.0)设计 DSL:采样->校验->转计划全通;缺必填块必须报错(fail-closed);
|
|
64
|
+
同 brief 两次采样一致;两两至少 2 维不同("伪多方案"门禁)
|
|
65
|
+
57 (v1.0.1)零模板:仓库里不许存在 presets/ 与 presetkit.py;现场采样必须确定性、
|
|
66
|
+
且换 brief 必须换结果(否则就是还在套模板)
|
|
67
|
+
58 (v1.0.0)提案闸:absent / draft / broken 三档判定正确(confirmed 才放行出片)
|
|
68
|
+
59 (v1.0.1)逐 stage 可单独跑:render 里每处 resolve_style 都必须拿得到 work
|
|
69
|
+
(只跑 --stage frames / --stage sfx 时缺参数会报"找不到设计",all 被缓存掩盖)
|
|
70
|
+
60 (v1.0.1)brief 里的要求真的生效:自然语言要解析成 must_have/avoid,
|
|
71
|
+
配色约束按色相窗口生效(早先整句被塞进 topic,"深蓝"采出来是橙色)
|
|
58
72
|
|
|
59
73
|
说明:本文件里 CASES 的**实际条数**是唯一真理,上面的编号表要与它同步。
|
|
60
|
-
v0.8.0
|
|
74
|
+
这张表已经被"自己没被自检"坑过两次:v0.8.0 之前停在 43 条而 CASES 已有 51 条;
|
|
75
|
+
v1.0.0 之前停在 51 条而 CASES 已有 58 条(52–58 一条都没列)。
|
|
76
|
+
改 CASES 时请同批改这张表,别让它再次落后。
|
|
61
77
|
"""
|
|
62
78
|
import argparse
|
|
63
79
|
import io
|
|
@@ -104,11 +120,37 @@ def run(name, fn):
|
|
|
104
120
|
except AssertionError as e:
|
|
105
121
|
RESULTS.append(("FAIL", name, str(e)))
|
|
106
122
|
print(" FAIL %s -> %s" % (name, e))
|
|
123
|
+
except SystemExit as e:
|
|
124
|
+
# 被调代码用 SystemExit 报"用法错误"(例如 resolve_style 要求先提案)。
|
|
125
|
+
# 必须记 FAIL 而不是让自检直接退出 —— 早先 selfcheck 会在这里**静默中止**,
|
|
126
|
+
# 后面的用例一条都不跑,输出留在半截,很容易被当成"还在跑"。
|
|
127
|
+
RESULTS.append(("FAIL", name, "SystemExit: %s" % (e.code if str(e.code) != "None" else e)))
|
|
128
|
+
print(" FAIL %s -> SystemExit: %s" % (name, e))
|
|
107
129
|
except Exception as e: # noqa: BLE001
|
|
108
130
|
RESULTS.append(("FAIL", name, "%s: %s" % (type(e).__name__, e)))
|
|
109
131
|
print(" FAIL %s -> %s: %s" % (name, type(e).__name__, e))
|
|
110
132
|
|
|
111
133
|
|
|
134
|
+
# 自检专用:造一份 design 计划。
|
|
135
|
+
# v1.0.1 删掉内置风格表后,"要一份 plan"这件事只能靠现场采样 + 转计划;
|
|
136
|
+
# 每个用例各写一遍既啰嗦又容易漂移,于是收到这里。
|
|
137
|
+
# 固定 seed 保证自检自身可复现(自检必须可复现,否则 CI 抖起来没法查)。
|
|
138
|
+
_PLAN_CACHE = {}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _design_plan(topic="selfcheck 样例", seed=20260101):
|
|
142
|
+
key = (topic, seed)
|
|
143
|
+
if key not in _PLAN_CACHE:
|
|
144
|
+
import designkit as DK
|
|
145
|
+
d = DK.sample_designs({"topic": topic}, n=1, seed=seed)[0]
|
|
146
|
+
_PLAN_CACHE[key] = (d, DK.design_to_plan(d))
|
|
147
|
+
return _PLAN_CACHE[key]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _sample_design(topic="selfcheck 样例", seed=20260101):
|
|
151
|
+
return _design_plan(topic, seed)[0]
|
|
152
|
+
|
|
153
|
+
|
|
112
154
|
def _tone_wav(path, seconds=1.0, freq=440.0):
|
|
113
155
|
import numpy as np
|
|
114
156
|
n = int(seconds * K.SR)
|
|
@@ -451,9 +493,15 @@ def t_music_plan():
|
|
|
451
493
|
K.wav_write(os.path.join(work, "music.wav"), np.stack([loud, loud], axis=1))
|
|
452
494
|
buf = io.StringIO()
|
|
453
495
|
with redirect_stdout(buf):
|
|
454
|
-
|
|
496
|
+
# D3 之后没有 design/style 的剧本会被拒(不再静默落 legacy),
|
|
497
|
+
# 这条用例测的是"off 不读旧配乐",与设计无关,显式给一个风格即可。
|
|
498
|
+
# D3 之后没有 design/style 的剧本会被拒(不再静默落 legacy),
|
|
499
|
+
# 这条用例测的是"off 不读旧配乐",与设计无关,给一份现场设计即可。
|
|
500
|
+
_d, _plan = _design_plan("配乐来源判定")
|
|
501
|
+
_script23 = {"title": "配乐来源", "_style": _plan}
|
|
502
|
+
off_out = render.st_audio(tl, work, dict(_script23, music={"mode": "off"}))
|
|
455
503
|
off_peak = float(np.max(np.abs(K.wav_read(off_out)[0])))
|
|
456
|
-
file_out = render.st_audio(tl, work,
|
|
504
|
+
file_out = render.st_audio(tl, work, dict(_script23, music={"mode": "file"}))
|
|
457
505
|
file_peak = float(np.max(np.abs(K.wav_read(file_out)[0])))
|
|
458
506
|
assert "music.mode=off" in buf.getvalue(), "off 没有打印说明(静默跳过也不行)"
|
|
459
507
|
assert off_peak < 0.01, "music.mode=off 仍把旧 music.wav 混进来了(峰值 %.3f)" % off_peak
|
|
@@ -552,79 +600,90 @@ def t_qa_frames():
|
|
|
552
600
|
|
|
553
601
|
|
|
554
602
|
def t_style_kits():
|
|
555
|
-
"""25
|
|
603
|
+
"""25 连续配色空间(v1.0.1 替代"8 套主题"):现场采样的每一套都必须过对比度门禁,
|
|
604
|
+
且色相真的分散——这条是"没有模板"的硬证据。
|
|
605
|
+
|
|
606
|
+
旧断言是"8 套内置主题全部过 lint + 自动选风格能分出 >=6 种"。模板删掉后那个断言
|
|
607
|
+
失去对象,于是换成更强的:**连续空间采样 12 次,全部可读 + 色相覆盖 >= 180°**。
|
|
608
|
+
只测"能采出来"不够 —— 全落在同一色相也能过,所以必须夹一条分布断言。
|
|
609
|
+
"""
|
|
610
|
+
import designkit as DK
|
|
556
611
|
import stylekit as ST
|
|
557
|
-
|
|
558
|
-
assert
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
612
|
+
designs = DK.sample_designs({"topic": "配色空间校验"}, n=12, seed=20260101)
|
|
613
|
+
assert len(designs) == 12, "采样返回 %d 套(要 12)" % len(designs)
|
|
614
|
+
hues, fails = [], []
|
|
615
|
+
for d in designs:
|
|
616
|
+
plan = DK.design_to_plan(d)
|
|
617
|
+
bad = ST.theme_lint(plan["theme"])
|
|
618
|
+
if bad:
|
|
619
|
+
fails.append("%s: %s" % (d["label"], bad[0][:50]))
|
|
620
|
+
h, _s, _v = ST.rgb_to_hsv(plan["theme"]["bg"])
|
|
621
|
+
hues.append(h)
|
|
622
|
+
assert not fails, "有设计过不了对比度门禁:%s" % ";".join(fails[:3])
|
|
623
|
+
span = max(hues) - min(hues)
|
|
624
|
+
if span < 180.0: # 跨 0° 的情况:取补集
|
|
625
|
+
span = 360.0 - span
|
|
626
|
+
assert span >= 180.0, ("12 套设计的底色谱相只覆盖 %.0f°(应 >=180°)——"
|
|
627
|
+
"说明配色并没有真的在连续空间里散开" % span)
|
|
628
|
+
# 配色必须是**连续**的,不是一份清单:断言"色相是连续量"而不是"落在 8 个点之一"。
|
|
629
|
+
# 做法:取 12 套的色相,检查它们不是某个固定集合的子集(容差 1°)。
|
|
630
|
+
# 早先这里断言的是 `len(PALETTE_FAMILIES) == len(_LEGACY_FAMILY_ANCHOR)` ——
|
|
631
|
+
# 而那两个名字是**同一个对象**,长度当然永远相等,等于什么也没验。
|
|
632
|
+
assert not DK.PALETTE_FAMILIES, "PALETTE_FAMILIES 必须为空(有内容=偷偷恢复了外观清单)"
|
|
633
|
+
rounded = sorted({round(h) for h in hues})
|
|
634
|
+
spread = len(rounded) / float(len(hues))
|
|
635
|
+
assert spread >= 0.8, ("12 套配色的色相只有 %d 个不同取值(%s)——"
|
|
636
|
+
"说明配色退化成了一份离散清单" % (len(rounded), rounded))
|
|
637
|
+
return "12 套现场配色全部过 lint;色相覆盖 %.0f°;%d/12 个不同色相" % (
|
|
638
|
+
span, len(rounded))
|
|
577
639
|
|
|
578
640
|
|
|
579
641
|
def t_style_legacy():
|
|
580
|
-
"""26 legacy
|
|
642
|
+
"""26 legacy 已移除(v1.0 / D3):不写 style 必须报错并给出迁移指引,而不是静默落老外观。
|
|
643
|
+
|
|
644
|
+
这条以前盯的是"老项目无痛升级";D3 之后那句话不再成立——legacy 路径整个删掉了,
|
|
645
|
+
因为它是同质化的另一半根因(不写 style = 全片一套固定外观 + 120BPM/Dm)。
|
|
646
|
+
新断言分成两半:① 不给设计时必须**报错**且错误文本指得出下一步;
|
|
647
|
+
② 显式给 style 时旧路径仍可用(P3 只停了 auto 与 legacy,没删显式选风格)。
|
|
648
|
+
"""
|
|
581
649
|
import stylekit as ST
|
|
582
650
|
s = {"title": "旧项目", "lines": [{"id": "a", "kind": "body", "text": "x"}]}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
#
|
|
594
|
-
|
|
595
|
-
assert ST.resolve_script_style(s2)["legacy"] is True
|
|
596
|
-
# 显式风格则必须真的换掉三件套
|
|
651
|
+
# ① 不给设计:必须报错,且错误文本指得出下一步
|
|
652
|
+
try:
|
|
653
|
+
ST.resolve_script_style(s)
|
|
654
|
+
except (ValueError, KeyError, SystemExit) as e:
|
|
655
|
+
msg = str(getattr(e, "code", None) or e)
|
|
656
|
+
assert "propose.py" in msg, "报错必须给出迁移指引(要提到 propose.py),实际:%r" % msg[:120]
|
|
657
|
+
assert "design" in msg.lower(), "报错要说清主路径是 design.json,实际:%r" % msg[:120]
|
|
658
|
+
else:
|
|
659
|
+
raise AssertionError("没有 style/design 却没报错:legacy 路径没有真的移除")
|
|
660
|
+
# ② 显式 style 也不再可用 —— v1.0.1 把内置风格表整个删了。
|
|
661
|
+
# 和 v1.0 不同:那时"显式选风格"是兼容面;现在连 STYLES 都没了,
|
|
662
|
+
# 任何 style id 都必须落空并报错(否则就是"模板还在")。
|
|
597
663
|
s3 = dict(s, style="cinema")
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
664
|
+
try:
|
|
665
|
+
ST.resolve_script_style(s3)
|
|
666
|
+
except (ValueError, KeyError, SystemExit) as e:
|
|
667
|
+
msg = str(getattr(e, "code", None) or e)
|
|
668
|
+
assert "模板" in msg or "design" in msg.lower() or "propose" in msg, "显式 style 报错也该给指引,实际:%r" % msg[:120]
|
|
669
|
+
else:
|
|
670
|
+
raise AssertionError("显式 style='cinema' 竟然还能解析出计划:内置风格表没删干净")
|
|
671
|
+
assert ST.STYLE_IDS == () and not ST.STYLES and not ST.THEMES, "内置风格/主题表必须为空,实际 STYLE_IDS=%r STYLES=%d THEMES=%d" % (
|
|
672
|
+
ST.STYLE_IDS, len(ST.STYLES), len(ST.THEMES))
|
|
673
|
+
return "无设计必报错并给迁移指引 / 显式 style 也被拒绝 / 三张模板表为空"
|
|
604
674
|
|
|
605
675
|
|
|
606
676
|
def t_style_determinism():
|
|
607
677
|
"""27 指纹确定性:同剧本同结果;改错别字不换风格;改题材才换。"""
|
|
608
|
-
import
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
assert
|
|
615
|
-
"
|
|
616
|
-
|
|
617
|
-
typo["lines"][1]["text"] += "。"
|
|
618
|
-
c = ST.resolve_script_style(typo)
|
|
619
|
-
assert c["style"] == a["style"], "改一个标点就换了风格: %s -> %s" % (a["style"], c["style"])
|
|
620
|
-
assert c["music"]["bpm"] == a["music"]["bpm"], "改一个标点就换了 BPM"
|
|
621
|
-
assert c["fingerprint"]["full"] != a["fingerprint"]["full"], "full 指纹没变(内容派生失效)"
|
|
622
|
-
other = {"title": "深夜学习指南", "style": "auto",
|
|
623
|
-
"lines": [{"id": "l01", "kind": "hook", "text": "为什么深夜反而能专注"},
|
|
624
|
-
{"id": "l02", "kind": "body", "text": "整理笔记,写作,阅读都更安静"}]}
|
|
625
|
-
d = ST.resolve_script_style(other)
|
|
626
|
-
assert d["style"] != a["style"], "换了一部片风格却相同: %s" % d["style"]
|
|
627
|
-
return "%s -> %s(同一剧本稳定),换片 -> %s" % (a["style"], c["style"], d["style"])
|
|
678
|
+
import designkit as DK
|
|
679
|
+
# v1.0.1:没有"剧本 -> 自动选风格"了,改为验证"设计采样"的确定性与区分度。
|
|
680
|
+
# 固定 seed 才谈确定性:默认不给 seed 时每次都要不同(那是 56/57 条在守的事)。
|
|
681
|
+
a = DK.sample_designs({"topic": "AI 大模型的三年"}, n=1, seed=11)[0]
|
|
682
|
+
b = DK.sample_designs({"topic": "AI 大模型的三年"}, n=1, seed=11)[0]
|
|
683
|
+
assert DK.design_fingerprint(a) == DK.design_fingerprint(b), "同 seed 同 brief 采样不一致"
|
|
684
|
+
assert DK.design_fingerprint(a) != DK.design_fingerprint(
|
|
685
|
+
DK.sample_designs({"topic": "手工木作"}, n=1, seed=11)[0]), "换了题材设计却没变"
|
|
686
|
+
return "同 seed 可复现 + 换题材必变"
|
|
628
687
|
|
|
629
688
|
|
|
630
689
|
def t_voice_plan():
|
|
@@ -633,31 +692,35 @@ def t_voice_plan():
|
|
|
633
692
|
import voicekit as VK
|
|
634
693
|
except Exception: # noqa: BLE001
|
|
635
694
|
return "skip"
|
|
636
|
-
import
|
|
637
|
-
s = {"title": "AI 大模型",
|
|
695
|
+
import designkit as DK
|
|
696
|
+
s = {"title": "AI 大模型",
|
|
638
697
|
"lines": [{"id": "l01", "kind": "hook", "text": "大模型改变了什么"},
|
|
639
698
|
{"id": "l02", "kind": "body", "text": "算法和数据的规模一起上去了"},
|
|
640
699
|
{"id": "l03", "kind": "quote", "text": "他说:这不可能"},
|
|
641
700
|
{"id": "l04", "kind": "outro", "text": "它已经在我们身边了"}]}
|
|
642
|
-
|
|
701
|
+
# v1.0.1:声线计划同样只能从设计来(模板表已删)
|
|
702
|
+
plan = DK.design_to_plan(DK.sample_designs({"topic": "AI 大模型"}, n=1, seed=7)[0])
|
|
643
703
|
vp = VK.voice_plan(s, plan)
|
|
644
704
|
assert len(vp) == 4, "计划行数应为 4,收到 %d" % len(vp)
|
|
645
705
|
assert json.dumps(vp, sort_keys=True, default=str) == json.dumps(
|
|
646
706
|
VK.voice_plan(s, plan), sort_keys=True, default=str), "voice_plan 不确定"
|
|
647
|
-
|
|
648
|
-
|
|
707
|
+
# v1.0 / D3:persona 身份表已删,声音身份的唯一表示是 edge voice id。
|
|
708
|
+
# 断言从"至少 2 个 persona"改成"至少 2 个不同的 voice id"——语义不变,字段换了。
|
|
709
|
+
voices = {r["voice"] for r in vp}
|
|
710
|
+
assert len(voices) >= 2, "4 句样片只用了一个声线: %s" % voices
|
|
649
711
|
tones = {r["tone"] for r in vp}
|
|
650
712
|
assert len(tones) >= 2, "语气档没有变化: %s" % tones
|
|
651
713
|
for r in vp:
|
|
652
714
|
for fld in ("voice", "rate", "pitch", "volume", "lead", "tail"):
|
|
653
715
|
assert r.get(fld) not in (None, ""), "计划行缺 %s: %r" % (fld, r.get("id"))
|
|
654
|
-
#
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
assert
|
|
660
|
-
|
|
716
|
+
# 单句片:同样走 design(不再有 style="mono" 这种内置风格可选)
|
|
717
|
+
one = {"title": "单句片",
|
|
718
|
+
"lines": [{"id": "a", "kind": "body", "text": "一句话"}]}
|
|
719
|
+
op = DK.design_to_plan(DK.sample_designs({"topic": "单句片"}, n=1, seed=3)[0])
|
|
720
|
+
ovp = VK.voice_plan(one, op)
|
|
721
|
+
assert len(ovp) == 1, "单句片应只给一行计划,收到 %d" % len(ovp)
|
|
722
|
+
assert ovp[0]["voice"].endswith("Neural"), "声线应是 edge voice id:%r" % (ovp[0]["voice"],)
|
|
723
|
+
return "%d 句 / %d 把声音 / %d 档语气" % (len(vp), len(voices), len(tones))
|
|
661
724
|
|
|
662
725
|
|
|
663
726
|
def t_font_family():
|
|
@@ -704,11 +767,13 @@ def t_font_family():
|
|
|
704
767
|
assert K.wrap_cn_cached("旧调用", 40, 500) is not None
|
|
705
768
|
# 字幕主题:浅色主题不该画黑背板
|
|
706
769
|
from PIL import Image
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
770
|
+
# v1.0.1:没有内置主题可借(THEMES 已清空),改用手写一份浅色主题。
|
|
771
|
+
# 这条断言测的是 draw_subtitle 的"浅底不画黑背板"逻辑,与主题从哪来无关。
|
|
772
|
+
th = {"bg": (247, 244, 238), "bg2": (233, 228, 218), "surface": (255, 255, 255),
|
|
773
|
+
"ink": (26, 24, 22), "muted": (110, 104, 96), "dim": (150, 146, 140),
|
|
774
|
+
"accent": (198, 96, 40), "accent2": (52, 96, 160),
|
|
775
|
+
"good": (46, 150, 92), "bad": (200, 60, 60), "rule": (208, 202, 192),
|
|
776
|
+
"id": "selfcheck-light", "label": "浅色测试"}
|
|
712
777
|
if th is not None:
|
|
713
778
|
img = Image.new("RGB", (K.W, K.H), th["bg"])
|
|
714
779
|
K.draw_subtitle(img, "浅色主题字幕", theme=th)
|
|
@@ -841,8 +906,9 @@ def t_parallel_frames():
|
|
|
841
906
|
"""
|
|
842
907
|
import json as _json
|
|
843
908
|
import tempfile
|
|
844
|
-
|
|
845
|
-
|
|
909
|
+
# v1.0.1:不再有内置主题可借,用现场设计的主题(颜色同样是 (r,g,b) 元组)
|
|
910
|
+
_d, _plan0 = _design_plan("并行渲染主题序列化")
|
|
911
|
+
th = dict(_plan0["theme"])
|
|
846
912
|
# ① 防御层:scenes._hashable 把 list 冻结成 tuple
|
|
847
913
|
scenes = _load_scenes_mod()
|
|
848
914
|
if scenes is not None and hasattr(scenes, "_hashable"):
|
|
@@ -854,18 +920,29 @@ def t_parallel_frames():
|
|
|
854
920
|
assert isinstance(round_trip["bg"], list), "前提变了:JSON 居然保住了 tuple"
|
|
855
921
|
w = tempfile.mkdtemp(prefix="sc_par_")
|
|
856
922
|
snap = os.path.join(w, "snap.json")
|
|
857
|
-
script = {"title": "t", "
|
|
858
|
-
script["_style"] =
|
|
923
|
+
script = {"title": "t", "lines": [{"text": "x"}]}
|
|
924
|
+
script["_style"] = _plan0
|
|
859
925
|
with open(snap, "w", encoding="utf-8") as fh:
|
|
860
926
|
_json.dump(script, fh, ensure_ascii=False)
|
|
861
927
|
back = _json.load(open(snap, encoding="utf-8"))
|
|
862
928
|
assert isinstance(back["_style"]["theme"]["bg"], list), "前提变了"
|
|
863
|
-
#
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
929
|
+
# 子进程路径(v1.0.1 修:worker 必须从 work/design.json 重解设计,而不是回落 stylekit)。
|
|
930
|
+
# 现在直接验那条路径:把 design.json 放进 work/,resolve_style 必须解出**同一个**设计,
|
|
931
|
+
# 且颜色回到 tuple(list 会让 _bg() 的缓存键不可哈希)。
|
|
932
|
+
import designkit as DK
|
|
933
|
+
import render as R
|
|
934
|
+
dpath = os.path.join(w, "design.json")
|
|
935
|
+
with open(dpath, "w", encoding="utf-8") as fh:
|
|
936
|
+
_json.dump(_sample_design("并行渲染主题序列化"), fh, ensure_ascii=False)
|
|
937
|
+
sub_script = {"title": "t", "lines": [{"text": "x"}]}
|
|
938
|
+
got = R.resolve_style(sub_script, w, verbose=False)
|
|
939
|
+
assert got.get("style", "").startswith("design:"), \
|
|
940
|
+
"子进程重解没有走 design.json(会画出另一部片):%r" % (got.get("style"),)
|
|
941
|
+
assert isinstance(got["theme"]["bg"], tuple), (
|
|
942
|
+
"重解后主题色仍是 list,_bg() 会 TypeError(这正是被修的 bug):%r"
|
|
943
|
+
% (got["theme"]["bg"],))
|
|
944
|
+
assert got["theme"]["bg"] == th["bg"], "重解出的主题与父进程不一致(并行会画出不同的片)"
|
|
945
|
+
return "主题可哈希 + JSON 往返 + 按 design.json 重解一致"
|
|
869
946
|
|
|
870
947
|
|
|
871
948
|
def _load_scenes_mod():
|
|
@@ -1138,17 +1215,32 @@ def t_theme_overrides_linted():
|
|
|
1138
1215
|
实测 overrides ink=[30,30,30]/bg=[10,10,10]:基础主题 lint 为空,最终主题 lint 报对比度 1.09。
|
|
1139
1216
|
"""
|
|
1140
1217
|
import stylekit as ST
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1218
|
+
# v1.0.1:没有"基础主题 vs 最终主题"的区分了(内置主题表已删),
|
|
1219
|
+
# 但"overrides 之后必须重新 lint"这条规则仍然要守:
|
|
1220
|
+
# 直接把设计主题改成低对比度,theme_lint 必须抓到。
|
|
1221
|
+
_d, _plan43 = _design_plan("theme_overrides 体检")
|
|
1222
|
+
base_th = _plan43["theme"]
|
|
1223
|
+
assert ST.theme_lint(base_th) == [], \
|
|
1224
|
+
"前提变了:设计主题本身就有 lint 问题,这条断言失去意义"
|
|
1225
|
+
dirty = ST.apply_theme_overrides(base_th, {"ink": [30, 30, 30], "bg": [10, 10, 10]})
|
|
1226
|
+
bad = ST.theme_lint(dirty)
|
|
1148
1227
|
assert bad, '低对比度的 theme_overrides 没被 lint 抓到(用户能造出看不清的字幕)'
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1228
|
+
# v1.0.1:体检的位置从 render 挪到了 designkit(design_to_plan 里就 lint,不过直接抛)。
|
|
1229
|
+
# 这里改查**真正干活的地方**——早先查 render.py 的源码文本,那只是"哪个文件里写了这行",
|
|
1230
|
+
# 一旦重构搬家就会误报(本次就是这样:lint 逻辑搬进 designkit 后断言还在查 render)。
|
|
1231
|
+
# 更硬的检查:直接把一份低对比度主题喂给 design_to_plan,必须抛。
|
|
1232
|
+
import designkit as DK
|
|
1233
|
+
dirty_design = json.loads(json.dumps(_sample_design("theme_overrides 体检")))
|
|
1234
|
+
dirty_design["visual"]["palette"]["ink"] = [30, 30, 30]
|
|
1235
|
+
dirty_design["visual"]["palette"]["bg"] = [10, 10, 10]
|
|
1236
|
+
try:
|
|
1237
|
+
DK.design_to_plan(dirty_design)
|
|
1238
|
+
except (ValueError, KeyError, SystemExit) as e:
|
|
1239
|
+
msg = str(getattr(e, "code", None) or e)
|
|
1240
|
+
assert "对比度" in msg or "lint" in msg.lower(), \
|
|
1241
|
+
"低对比度设计被拒了,但错误信息没说是对比度问题:%r" % msg[:120]
|
|
1242
|
+
else:
|
|
1243
|
+
raise AssertionError("低对比度主题竟然通过了 design_to_plan(体检失效,能造出看不清的字幕)")
|
|
1152
1244
|
return "最终主题参与体检(%s)" % ";".join(bad)[:60]
|
|
1153
1245
|
|
|
1154
1246
|
|
|
@@ -1227,12 +1319,9 @@ def t_golden_determinism():
|
|
|
1227
1319
|
import hashlib
|
|
1228
1320
|
sc = dict(id="l01", kind="hook", start=0.0, dur=4.0, chapter="01 · 测试",
|
|
1229
1321
|
text="金帧确定性自检", sub="金帧确定性", big="金帧确定性")
|
|
1322
|
+
# v1.0.1:剧本必须显式给设计(内置风格表已删,没有"style": "mono" 这种写法了)。
|
|
1230
1323
|
script = {"title": "selfcheck", "_total": 8.0}
|
|
1231
|
-
|
|
1232
|
-
plan = __import__("stylekit").resolve_script_style(dict(script, lines=[sc]))
|
|
1233
|
-
script["_style"] = plan
|
|
1234
|
-
except Exception: # noqa: BLE001
|
|
1235
|
-
pass
|
|
1324
|
+
script["_style"] = _design_plan("金帧确定性")[1]
|
|
1236
1325
|
hs = []
|
|
1237
1326
|
for _ in range(2):
|
|
1238
1327
|
fr = mod.draw_frame(sc, 1.25, 4.0, 1.25, script, 37)
|
|
@@ -1350,18 +1439,23 @@ def t_web_style_payload():
|
|
|
1350
1439
|
import webengine as we
|
|
1351
1440
|
except Exception as e: # noqa: BLE001
|
|
1352
1441
|
return "skip(没有 webengine.py:%s)" % e
|
|
1353
|
-
|
|
1354
|
-
|
|
1442
|
+
# v1.0.1:没有 legacy 概念了(legacy 入口已被拒绝),所以这里只验"设计项目必须注入载荷"。
|
|
1443
|
+
script = {"title": "x", "lines": [{"id": "l01", "kind": "hook", "text": "y"}]}
|
|
1444
|
+
script["_style"] = _design_plan("web 载荷")[1]
|
|
1355
1445
|
tl = {"fps": 30, "video": {"out_w": 1280, "out_h": 720}}
|
|
1356
|
-
|
|
1446
|
+
try:
|
|
1447
|
+
payload = R._web_style_payload(we, script, tl)
|
|
1448
|
+
except SystemExit as e:
|
|
1449
|
+
raise AssertionError("有 _style 却被当成没有设计:%s" % (e,)) from e
|
|
1357
1450
|
assert payload is not None, "非 legacy 项目没有生成 style 载荷(页面不会跟随风格)"
|
|
1358
1451
|
canvas = payload.get("canvas") or {}
|
|
1359
1452
|
assert int(canvas.get("w") or 0) == 1280 and int(canvas.get("h") or 0) == 720, \
|
|
1360
1453
|
"画布尺寸没跟着 video 块走:%r" % (canvas,)
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1454
|
+
# 没有设计的剧本:必须回落 None(不是"随便注入一套默认风格")
|
|
1455
|
+
bare = {"title": "x", "lines": [{"id": "l01", "kind": "hook", "text": "y"}]}
|
|
1456
|
+
assert R._web_style_payload(we, bare, tl) is None, \
|
|
1457
|
+
"没有设计的剧本也注入了 style 载荷(等于凭空套一套外观)"
|
|
1458
|
+
return "有设计注入 %dx%d / 无设计回落 None" % (canvas["w"], canvas["h"])
|
|
1365
1459
|
|
|
1366
1460
|
|
|
1367
1461
|
def t_golden_frames():
|
|
@@ -1407,6 +1501,632 @@ def t_golden_frames():
|
|
|
1407
1501
|
status or "-", p.returncode, (d.get("reason") or "")[:120])
|
|
1408
1502
|
|
|
1409
1503
|
|
|
1504
|
+
# ============================================================================
|
|
1505
|
+
# v0.9:场景语法 + 视觉语言
|
|
1506
|
+
# ============================================================================
|
|
1507
|
+
|
|
1508
|
+
def _load_grammar():
|
|
1509
|
+
"""加载 grammar.py(纯标准库)。找不到返回 None —— CI 里可能只拷了 scripts/ 的一部分。"""
|
|
1510
|
+
try:
|
|
1511
|
+
import grammar
|
|
1512
|
+
return grammar
|
|
1513
|
+
except Exception: # noqa: BLE001
|
|
1514
|
+
cand = os.path.join(HERE, "grammar.py")
|
|
1515
|
+
if not os.path.exists(cand):
|
|
1516
|
+
return None
|
|
1517
|
+
import importlib.util
|
|
1518
|
+
spec = importlib.util.spec_from_file_location("sc_grammar", cand)
|
|
1519
|
+
mod = importlib.util.module_from_spec(spec)
|
|
1520
|
+
spec.loader.exec_module(mod)
|
|
1521
|
+
return mod
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _fake_scenes(G, lines, per=6.0):
|
|
1525
|
+
"""按固定时长造 timeline 段(不跑 TTS),只用来测选型。"""
|
|
1526
|
+
scenes, t = [], 0.0
|
|
1527
|
+
for ln in lines:
|
|
1528
|
+
row = dict(ln, start=round(t, 3), end=round(t + per, 3), dur=round(per, 3))
|
|
1529
|
+
scenes.append(row)
|
|
1530
|
+
t += per
|
|
1531
|
+
return scenes
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def t_scene_grammar():
|
|
1535
|
+
"""52 场景语法:10 类都能被选中、选型确定、段长门会降级。
|
|
1536
|
+
|
|
1537
|
+
为什么要有这条:v0.9 之前 quote/emphasis/calm 三种 kind 全部落到 body 绘制器,
|
|
1538
|
+
画面永远只有一套骨架。这条断言盯的是"内容 -> 构图"这条链路真的在分流,
|
|
1539
|
+
而不是"代码里写了 10 个函数但没人调用"。
|
|
1540
|
+
"""
|
|
1541
|
+
G = _load_grammar()
|
|
1542
|
+
if G is None:
|
|
1543
|
+
return "skip(没有 grammar.py)"
|
|
1544
|
+
lines = [
|
|
1545
|
+
{"id": "a", "kind": "hook", "chapter": "01", "text": "开场一句。", "big": "开场"},
|
|
1546
|
+
{"id": "b", "kind": "body", "chapter": "02", "text": "我们每天被打断 47 次。", "big": "47 次"},
|
|
1547
|
+
{"id": "c", "kind": "body", "chapter": "02", "text": "对比一下:深度工作四小时,产出是碎片时间的三倍。",
|
|
1548
|
+
"big": "四小时 vs 碎片", "items": [{"text": "深度工作"}, {"text": "碎片时间"}]},
|
|
1549
|
+
{"id": "d", "kind": "body", "chapter": "03", "text": "第一步清空收件箱,然后关掉通知,接着单线程,最后复盘。",
|
|
1550
|
+
"big": "四步", "items": [{"text": "清空"}, {"text": "关通知"}, {"text": "单线程"}, {"text": "复盘"}]},
|
|
1551
|
+
{"id": "e", "kind": "quote", "chapter": "03", "text": "他说:「深度工作稀缺。」", "quote": "深度工作稀缺"},
|
|
1552
|
+
{"id": "f", "kind": "body", "chapter": "04", "text": "各行业占比差异明显。", "big": "各行业占比",
|
|
1553
|
+
"items": [{"text": "A 62%"}, {"text": "B 51%"}, {"text": "C 38%"}]},
|
|
1554
|
+
{"id": "g", "kind": "body", "chapter": "05", "text": "注意力是换来的。", "big": "注意力是换来的"},
|
|
1555
|
+
{"id": "h", "kind": "body", "chapter": "06", "text": "第五章开始了。", "big": "第五章"},
|
|
1556
|
+
{"id": "i", "kind": "body", "chapter": "06", "text": "这里有五条要点要讲清楚。", "big": "五条要点",
|
|
1557
|
+
"items": [{"text": "一"}, {"text": "二"}, {"text": "三"}, {"text": "四"}, {"text": "五"}]},
|
|
1558
|
+
{"id": "j", "kind": "outro", "chapter": "99", "text": "开始就好。", "big": "开始就好"},
|
|
1559
|
+
]
|
|
1560
|
+
scenes = _fake_scenes(G, lines)
|
|
1561
|
+
plan = {"chapter_layout": {}, "layout_default": "left-stack", "style": "tech-deep"}
|
|
1562
|
+
sb1 = G.plan_scenes([dict(s) for s in scenes], {"title": "t"}, plan)
|
|
1563
|
+
got = [r["type"] for r in sb1["scenes"]]
|
|
1564
|
+
# ① 确定性:同一份输入两次结果完全一致(含变体号与理由)
|
|
1565
|
+
sb2 = G.plan_scenes([dict(s) for s in scenes], {"title": "t"}, plan)
|
|
1566
|
+
assert [r["type"] for r in sb2["scenes"]] == got, "同一份稿子两次选型不同(不确定性)"
|
|
1567
|
+
assert [r["variant"] for r in sb2["scenes"]] == [r["variant"] for r in sb1["scenes"]], \
|
|
1568
|
+
"同一份稿子两次变体不同(不确定性)"
|
|
1569
|
+
# ② 分流:至少命中 5 种不同类型(不是全退化成 list/statement)
|
|
1570
|
+
assert len(set(got)) >= 5, "10 段只分出 %d 种构图(%r):选型逻辑没在分流" % (len(set(got)), got)
|
|
1571
|
+
assert "stat" in got and "compare" in got and "quote" in got, \
|
|
1572
|
+
"数字/对比/引语三种特征没有各自分流:%r" % (got,)
|
|
1573
|
+
# ③ 段长门:把 chart 塞进 2 秒段必须降级,且理由里写明
|
|
1574
|
+
short = [{"id": "x", "kind": "body", "chapter": "01", "text": "短。", "big": "短",
|
|
1575
|
+
"type": "chart", "series": [{"label": "A", "value": 1}]}]
|
|
1576
|
+
sb3 = G.plan_scenes([dict(s) for s in _fake_scenes(G, short, per=2.0)],
|
|
1577
|
+
{"title": "t"}, plan)
|
|
1578
|
+
r3 = sb3["scenes"][0]
|
|
1579
|
+
assert r3["type"] != "chart", "2 秒的段仍选了 chart(段长门失效)"
|
|
1580
|
+
assert any("降级" in x or "排除" in x for x in r3["reason"]), \
|
|
1581
|
+
"降级没有留下理由:%r" % (r3["reason"],)
|
|
1582
|
+
# ④ 显式 type 优先
|
|
1583
|
+
ex = [{"id": "y", "kind": "body", "chapter": "01", "text": "随便一句", "big": "随便",
|
|
1584
|
+
"type": "hero"}]
|
|
1585
|
+
sb4 = G.plan_scenes([dict(s) for s in _fake_scenes(G, ex)], {"title": "t"}, plan)
|
|
1586
|
+
assert sb4["scenes"][0]["type"] == "hero", "显式 type=hero 没生效:%r" % (sb4["scenes"][0],)
|
|
1587
|
+
return "10 段 -> %d 种构图;降级 chart->%s;显式 type 生效" % (len(set(got)), r3["type"])
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def t_visual_languages():
|
|
1591
|
+
"""53 视觉语言包:8 套结构签名齐全、过体检、且彼此不是同一套。
|
|
1592
|
+
|
|
1593
|
+
为什么要有这条:v0.5 的"风格"只改配色,8 套风格骨架完全一样(用户抱怨的根因)。
|
|
1594
|
+
这条盯的是"结构签名真的存在且互不相同"——母题/转场/重心偏移任一维度相同不算问题,
|
|
1595
|
+
但**五组签名全同**就说明这套语言只是复制粘贴。
|
|
1596
|
+
"""
|
|
1597
|
+
import designkit as DK
|
|
1598
|
+
import stylekit as ST
|
|
1599
|
+
# v1.0.1:没有 8 套内置语言了。改成验"现场生成的语言",而且断言更强:
|
|
1600
|
+
# 12 个设计的两两结构签名必须互不相同(以前只要求 8 套里 >=6 种母题)。
|
|
1601
|
+
sigs, motifs, transes = {}, set(), set()
|
|
1602
|
+
designs = DK.sample_designs({"topic": "视觉语言多样性"}, n=12, seed=20260202)
|
|
1603
|
+
for d in designs:
|
|
1604
|
+
plan = DK.design_to_plan(d)
|
|
1605
|
+
vl = plan["language"]
|
|
1606
|
+
bad = ST.visual_lint_language(vl)
|
|
1607
|
+
assert not bad, "%s 的语言包没过体检:%s" % (d["label"], bad[:2])
|
|
1608
|
+
key = (str((vl.get("motif") or {}).get("kind")),
|
|
1609
|
+
str((vl.get("transition") or {}).get("kind")),
|
|
1610
|
+
tuple(sorted((vl.get("bias") or {}).items())))
|
|
1611
|
+
sigs.setdefault(key, []).append(d["label"])
|
|
1612
|
+
motifs.add(str((vl.get("motif") or {}).get("kind")))
|
|
1613
|
+
transes.add(str((vl.get("transition") or {}).get("kind")))
|
|
1614
|
+
dup = [v for v in sigs.values() if len(v) > 1]
|
|
1615
|
+
assert not dup, "这些设计语言签名完全相同(等于只在换色): %s" % (dup[:2],)
|
|
1616
|
+
assert len(motifs) >= 6, "12 个设计只用了 %d 种装饰母题:%r" % (len(motifs), sorted(motifs))
|
|
1617
|
+
return "12 个设计 / %d 种母题 / %d 种转场 / 无重复签名" % (
|
|
1618
|
+
len(motifs), len(transes))
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def t_scene_types_single_truth():
|
|
1622
|
+
"""54 场景类型只有一份"真值集合":4 处定义必须集合相等(顺序可以不同)。
|
|
1623
|
+
|
|
1624
|
+
为什么比集合不比顺序:grammar.SCENE_TYPES 的顺序是"平手取位顺序",改它会改变同一份
|
|
1625
|
+
稿子的选型结果,所以它有独立语义;filmcheck/schema/scenes 只关心"有哪些类型"。
|
|
1626
|
+
顺序不同是设计,集合不同是 bug(写错一个字母 = 某个类型永远画不出来)。
|
|
1627
|
+
"""
|
|
1628
|
+
G = _load_grammar()
|
|
1629
|
+
if G is None:
|
|
1630
|
+
return "skip(没有 grammar.py)"
|
|
1631
|
+
here_scripts = HERE
|
|
1632
|
+
got = {"grammar": set(G.SCENE_TYPES)}
|
|
1633
|
+
# filmcheck.SCENE_TYPES
|
|
1634
|
+
try:
|
|
1635
|
+
import filmcheck as FC
|
|
1636
|
+
got["filmcheck"] = set(FC.SCENE_TYPES)
|
|
1637
|
+
except Exception: # noqa: BLE001
|
|
1638
|
+
pass
|
|
1639
|
+
# assets/scenes.py 的 _DRAWERS 键
|
|
1640
|
+
mod = _load_scenes_mod()
|
|
1641
|
+
if mod is not None and hasattr(mod, "_DRAWERS"):
|
|
1642
|
+
got["scenes"] = set(mod._DRAWERS.keys())
|
|
1643
|
+
# schema 的 enum
|
|
1644
|
+
schema = os.path.join(os.path.dirname(os.path.dirname(here_scripts)),
|
|
1645
|
+
"..", "..", "schemas", "film.schema.json")
|
|
1646
|
+
schema = os.path.normpath(schema)
|
|
1647
|
+
if os.path.exists(schema):
|
|
1648
|
+
try:
|
|
1649
|
+
data = json.load(open(schema, encoding="utf-8"))
|
|
1650
|
+
enum = (data["properties"]["lines"]["items"]["properties"]["type"]["enum"])
|
|
1651
|
+
got["schema"] = set(enum)
|
|
1652
|
+
except Exception: # noqa: BLE001
|
|
1653
|
+
pass
|
|
1654
|
+
if len(got) < 3:
|
|
1655
|
+
return "skip(只找到 %d 处真值:%s)" % (len(got), sorted(got))
|
|
1656
|
+
base_name, base = sorted(got.items())[0]
|
|
1657
|
+
for name, s in got.items():
|
|
1658
|
+
if s != base:
|
|
1659
|
+
raise AssertionError("场景类型集合不一致:%s 与 %s 差 %r / %r"
|
|
1660
|
+
% (name, base_name, sorted(s - base), sorted(base - s)))
|
|
1661
|
+
return "%d 处真值集合一致(%d 类):%s" % (len(got), len(base), "/".join(sorted(got)))
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
def t_silhouette_gate():
|
|
1665
|
+
"""55 剪影可区分(G13):8 套风格两两之间构图骨架必须能区分。
|
|
1666
|
+
|
|
1667
|
+
这是 v0.9 的核心验收:如果这条挂了,说明"换风格只是换色"的毛病回来了。
|
|
1668
|
+
工具在 tools/silhouette_qa.py(可选):缺了 skip,不阻塞 CI。
|
|
1669
|
+
"""
|
|
1670
|
+
root = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
|
|
1671
|
+
tool = os.path.join(root, "tools", "silhouette_qa.py")
|
|
1672
|
+
if not os.path.exists(tool):
|
|
1673
|
+
return "skip(没有 tools/silhouette_qa.py)"
|
|
1674
|
+
p = subprocess.run([sys.executable, tool, "--json"],
|
|
1675
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1676
|
+
cwd=root)
|
|
1677
|
+
out = (p.stdout or "").strip()
|
|
1678
|
+
i = out.find("{")
|
|
1679
|
+
if i < 0:
|
|
1680
|
+
return "skip(silhouette_qa 没输出 JSON:%s)" % ((p.stderr or out) or "")[:120]
|
|
1681
|
+
try:
|
|
1682
|
+
d = json.loads(out[i:])
|
|
1683
|
+
except json.JSONDecodeError as e:
|
|
1684
|
+
return "skip(silhouette_qa JSON 解析失败:%s)" % e
|
|
1685
|
+
pairs = d.get("pairs") or []
|
|
1686
|
+
# 三档语义与工具对齐:status 只有 pass / fail / skip 三种。
|
|
1687
|
+
# 只认 returncode 会把"工具没渲出帧"(skip)也记成 PASS —— 那正是本仓库反复踩的坑。
|
|
1688
|
+
failed = [x for x in pairs if str(x.get("status")) == "fail"]
|
|
1689
|
+
skipped = [x for x in pairs if str(x.get("status")) == "skip"]
|
|
1690
|
+
if p.returncode != 0 or failed:
|
|
1691
|
+
names = ["%s×%s" % (x.get("a"), x.get("b")) for x in failed[:4]]
|
|
1692
|
+
raise AssertionError("剪影不可区分:%d/%d 对失败(%s);换风格仍只是换色"
|
|
1693
|
+
% (len(failed), len(pairs), ", ".join(names)))
|
|
1694
|
+
if skipped:
|
|
1695
|
+
return "skip(%d/%d 对没渲出来:%s)" % (
|
|
1696
|
+
len(skipped), len(pairs),
|
|
1697
|
+
", ".join("%s×%s" % (x.get("a"), x.get("b")) for x in skipped[:3]))
|
|
1698
|
+
# 同明暗族是"保守下界":跨族很多时候靠 ΔB(背景亮度差)就能过,那不是构图证据。
|
|
1699
|
+
# 所以这条断言在报告里必须把同族数字单独说出来,别让 28/28 被读成"8 套风格结构全不同"。
|
|
1700
|
+
same = ((d.get("summary") or {}).get("by_family") or {}).get("same") or {}
|
|
1701
|
+
same_n = same.get("pairs")
|
|
1702
|
+
same_ok = same.get("pass")
|
|
1703
|
+
if same_n and same_ok is not None and int(same_ok) < int(same_n):
|
|
1704
|
+
raise AssertionError("同明暗族只有 %s/%s 对可区分(保守下界不达标)"
|
|
1705
|
+
% (same_ok, same_n))
|
|
1706
|
+
return "%d 对全部可区分(同明暗族 %s/%s,保守下界也过)" % (
|
|
1707
|
+
len(pairs), same_ok if same_ok is not None else "?", same_n if same_n else "?")
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
def t_design_dsl():
|
|
1711
|
+
"""56 设计 DSL:采样 -> 校验 -> 转 plan,且 fail-closed(坏设计必须报错)。"""
|
|
1712
|
+
try:
|
|
1713
|
+
import designkit as DK
|
|
1714
|
+
except Exception: # noqa: BLE001
|
|
1715
|
+
return "skip(没有 designkit.py)"
|
|
1716
|
+
ds = DK.sample_designs({"topic": "selfcheck 设计 DSL"}, n=3)
|
|
1717
|
+
assert len(ds) == 3, "采样器只给了 %d 个候选" % len(ds)
|
|
1718
|
+
for d in ds:
|
|
1719
|
+
bad = DK.validate_design(d)
|
|
1720
|
+
assert not bad, "采样器吐出了不合格的设计 %r:%s" % (d.get("id"), bad)
|
|
1721
|
+
plan = DK.design_to_plan(d)
|
|
1722
|
+
assert plan["legacy"] is False, "design 计划不该标 legacy"
|
|
1723
|
+
assert str(plan["style"]).startswith("design:"), "style 应是 design:<id>:%r" % plan["style"]
|
|
1724
|
+
assert len(plan.get("theme") or {}) >= 30, "主题字段太少:%d" % len(plan.get("theme") or {})
|
|
1725
|
+
# 两两必须至少 2 个维度不同("伪多方案"门禁)
|
|
1726
|
+
for i in range(len(ds)):
|
|
1727
|
+
for j in range(i + 1, len(ds)):
|
|
1728
|
+
d = DK.design_distance(ds[i], ds[j])
|
|
1729
|
+
assert d["n"] >= 2, "候选 %s 与 %s 只差 %d 维" % (ds[i]["id"], ds[j]["id"], d["n"])
|
|
1730
|
+
# fail-closed:删掉必填块必须报错,不能静默回落
|
|
1731
|
+
broken = json.loads(json.dumps(ds[0]))
|
|
1732
|
+
broken["visual"].pop("motif", None)
|
|
1733
|
+
assert DK.validate_design(broken), "缺 motif 竟然通过校验(fail-closed 失效)"
|
|
1734
|
+
# v1.0.1 起语义反转:**不给 seed = 每次都不一样**(需求是"每条视频都不同"),
|
|
1735
|
+
# 给 seed 才可复现。两条都要测,否则改错了任一边都发现不了。
|
|
1736
|
+
x1 = [d["id"] for d in DK.sample_designs({"topic": "selfcheck 设计 DSL"}, n=3)]
|
|
1737
|
+
x2 = [d["id"] for d in DK.sample_designs({"topic": "selfcheck 设计 DSL"}, n=3)]
|
|
1738
|
+
assert x1 != x2, "同一 brief 两次采样完全相同(那就是把模板藏进了哈希里)"
|
|
1739
|
+
y1 = [d["id"] for d in DK.sample_designs({"topic": "selfcheck 设计 DSL"}, n=3, seed=42)]
|
|
1740
|
+
y2 = [d["id"] for d in DK.sample_designs({"topic": "selfcheck 设计 DSL"}, n=3, seed=42)]
|
|
1741
|
+
assert y1 == y2, "给了 seed 竟然不可复现(回归/文档示例就不可信了)"
|
|
1742
|
+
return "3 候选 / 校验全过 / 两两 >=2 维 / 缺字段必报错 / 默认每次不同 + seed 可复现"
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def t_no_templates():
|
|
1746
|
+
"""57 零模板:仓库里**不许再有**预设/模板文件(v1.0.1 起本技能只提供技术,不提供模板)。
|
|
1747
|
+
|
|
1748
|
+
反过来断言:早先这条是"presets/*.json 可读可转",v1.0.1 把预设整个删掉后,
|
|
1749
|
+
如果只是删掉这条检查,就再也拦不住"哪天有人又加一套预设进去"。
|
|
1750
|
+
所以改成**禁止存在**——删掉模板是一次性动作,守住它是长期契约。
|
|
1751
|
+
"""
|
|
1752
|
+
root = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
|
|
1753
|
+
skill = os.path.join(root, "skills", "video-studio")
|
|
1754
|
+
banned = []
|
|
1755
|
+
presets_dir = os.path.join(skill, "presets")
|
|
1756
|
+
if os.path.isdir(presets_dir):
|
|
1757
|
+
n = len([x for x in os.listdir(presets_dir) if x.endswith(".json")])
|
|
1758
|
+
banned.append("presets/ 目录还在(%d 个 json)" % n)
|
|
1759
|
+
for name in ("presetkit.py",):
|
|
1760
|
+
if os.path.exists(os.path.join(skill, "scripts", name)):
|
|
1761
|
+
banned.append("scripts/%s 还在" % name)
|
|
1762
|
+
assert not banned, ("v1.0.1 起本技能不提供模板,这些必须删掉:%s;"
|
|
1763
|
+
"要自定义请写 design.json(技术原语仍在,只是不再有现成目录)"
|
|
1764
|
+
% ";".join(banned))
|
|
1765
|
+
# 同时确认"现场采样"是唯一方式,且**每次都新**:
|
|
1766
|
+
# · 同一 brief 两次 -> 必须不同(否则等于模板藏在哈希里,用户要新片只能改措辞)
|
|
1767
|
+
# · 给 seed -> 必须可复现(回归与文档示例需要一个稳定值)
|
|
1768
|
+
try:
|
|
1769
|
+
import designkit as DK
|
|
1770
|
+
except Exception: # noqa: BLE001
|
|
1771
|
+
return "skip(没有 designkit.py)"
|
|
1772
|
+
a = [d["label"] for d in DK.sample_designs({"topic": "零模板校验 A"}, n=2)]
|
|
1773
|
+
b = [d["label"] for d in DK.sample_designs({"topic": "零模板校验 A"}, n=2)]
|
|
1774
|
+
c = [d["label"] for d in DK.sample_designs({"topic": "零模板校验 B"}, n=2)]
|
|
1775
|
+
s1 = [d["label"] for d in DK.sample_designs({"topic": "零模板校验 A"}, n=2, seed=7)]
|
|
1776
|
+
s2 = [d["label"] for d in DK.sample_designs({"topic": "零模板校验 A"}, n=2, seed=7)]
|
|
1777
|
+
assert a != b, "同一 brief 两次采样结果相同(等于把模板藏进了哈希)"
|
|
1778
|
+
assert a != c, "换 brief 采样结果没变(说明其实在套模板)"
|
|
1779
|
+
assert s1 == s2, "给了 seed 竟然不可复现"
|
|
1780
|
+
return "无模板文件;同 brief 两次必不同、换 brief 必变、seed 可复现"
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def t_brief_constraints():
|
|
1784
|
+
"""60 brief 里的要求必须真的生效(自然语言 -> 约束 -> 采样结果)。
|
|
1785
|
+
|
|
1786
|
+
这条防的是**需求被静默吃掉**:v1.0.1 之前 propose._brief_of 把整句 --brief 塞进 topic,
|
|
1787
|
+
must_have/avoid 恒为空数组,于是"深蓝配色的科技解说"采出来是 h087 / h316(橙 / 紫)。
|
|
1788
|
+
用户看到的需求和结果无关,而且没有任何提示——比报错更糟。
|
|
1789
|
+
|
|
1790
|
+
三件事都要验:
|
|
1791
|
+
① 自由文本能解析成 must_have / avoid(含否定词归到 avoid);
|
|
1792
|
+
② 配色约束按**色相窗口**生效(不是按 8 个族名);
|
|
1793
|
+
③ avoid 真的把色相排除掉。
|
|
1794
|
+
"""
|
|
1795
|
+
import re
|
|
1796
|
+
try:
|
|
1797
|
+
import propose as PP
|
|
1798
|
+
import designkit as DK
|
|
1799
|
+
import stylekit as ST
|
|
1800
|
+
except Exception: # noqa: BLE001
|
|
1801
|
+
return "skip(缺 propose/designkit/stylekit)"
|
|
1802
|
+
# ① 解析
|
|
1803
|
+
b1 = PP._brief_of("要深蓝,不要放射线", {"title": "t"})
|
|
1804
|
+
assert b1["must_have"] and "深蓝" in b1["must_have"][0], "must_have 没解析出来:%r" % (b1["must_have"],)
|
|
1805
|
+
assert b1["avoid"] and "放射" in b1["avoid"][0], "avoid 没解析出来:%r" % (b1["avoid"],)
|
|
1806
|
+
b2 = PP._brief_of("随便做个片", {"title": "t"})
|
|
1807
|
+
assert b2["must_have"] == [] and b2["avoid"] == [], "认不出要求时不该硬凑约束"
|
|
1808
|
+
# ② 配色约束 -> 色相窗口
|
|
1809
|
+
ds = DK.sample_designs({"topic": "深蓝科技", "must_have": ["深蓝"]}, n=2, seed=7)
|
|
1810
|
+
for d in ds:
|
|
1811
|
+
h = DK._design_bg_hue(d)
|
|
1812
|
+
assert h is not None, "取不到底色色相"
|
|
1813
|
+
gap = DK._hue_gap(h, DK._PALETTE_ANCHOR["deep-blue"][0])
|
|
1814
|
+
assert gap <= DK._HUE_WINDOW + 1e-6, (
|
|
1815
|
+
"must_have『深蓝』没生效:底色色相 %.1f° 距 210° 有 %.1f°(窗口 %.0f°)"
|
|
1816
|
+
% (h, gap, DK._HUE_WINDOW))
|
|
1817
|
+
# ③ avoid 真的排除
|
|
1818
|
+
mint = DK._PALETTE_ANCHOR["cool-mint"][0]
|
|
1819
|
+
for i in range(3):
|
|
1820
|
+
d = DK.sample_designs({"topic": "避开薄荷 %d" % i, "avoid": ["薄荷"]}, n=1, seed=i)[0]
|
|
1821
|
+
h = DK._design_bg_hue(d)
|
|
1822
|
+
assert DK._hue_gap(h, mint) > DK._HUE_WINDOW, (
|
|
1823
|
+
"avoid『薄荷』没生效:底色色相 %.1f° 落在薄荷范围(%.0f°±%.0f°)"
|
|
1824
|
+
% (h, mint, DK._HUE_WINDOW))
|
|
1825
|
+
return "解析 2/2;深蓝落入窗口;薄荷 3/3 被排除"
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def t_stage_standalone():
|
|
1829
|
+
"""59 逐 stage 可单独跑:render 里每处 resolve_style 都必须能拿到 work。
|
|
1830
|
+
|
|
1831
|
+
这条守住一类**只在"单跑某个 stage"时才发作**的 bug:
|
|
1832
|
+
`--stage all` 时前面的 stage 已经把 script["_style"] 塞好了,后面全都命中缓存,
|
|
1833
|
+
所以缺参数也看不出来;一旦用户只跑 `--stage frames` / `--stage sfx`,
|
|
1834
|
+
resolve_style 读不到 work/design.json 就报"找不到设计"(v1.0.1 实测踩到:
|
|
1835
|
+
frames / sfx 两个 stage 单跑必挂,sfx 还是 NameError——函数里压根没有 work 这个名字)。
|
|
1836
|
+
|
|
1837
|
+
为什么用 AST 而不是真的跑每个 stage:跑一遍 = 要 TTS 联网 + 全片渲染,几十秒起步,
|
|
1838
|
+
不适合放进 20 秒的自检。静态检查能抓住"参数没传/名字不存在"这一整类问题,
|
|
1839
|
+
而这正是本次的真实缺陷形态。
|
|
1840
|
+
"""
|
|
1841
|
+
import ast
|
|
1842
|
+
src_path = os.path.join(HERE, "render.py")
|
|
1843
|
+
tree = ast.parse(open(src_path, encoding="utf-8").read())
|
|
1844
|
+
bad = []
|
|
1845
|
+
for node in ast.walk(tree):
|
|
1846
|
+
if not isinstance(node, ast.FunctionDef):
|
|
1847
|
+
continue
|
|
1848
|
+
args = [a.arg for a in node.args.args] + [a.arg for a in node.args.kwonlyargs]
|
|
1849
|
+
for sub in ast.walk(node):
|
|
1850
|
+
if not isinstance(sub, ast.Call):
|
|
1851
|
+
continue
|
|
1852
|
+
fn = sub.func
|
|
1853
|
+
name = getattr(fn, "id", None) or getattr(fn, "attr", None)
|
|
1854
|
+
if name != "resolve_style":
|
|
1855
|
+
continue
|
|
1856
|
+
# resolve_style(script, work) 的第二位必须是本函数能拿到的变量
|
|
1857
|
+
if len(sub.args) >= 2:
|
|
1858
|
+
second = sub.args[1]
|
|
1859
|
+
vname = getattr(second, "id", None)
|
|
1860
|
+
if vname and vname not in args and vname not in ("work",):
|
|
1861
|
+
# 明确允许 a.work 这类属性访问(不是 Name)
|
|
1862
|
+
bad.append("%s(): resolve_style 的 work 参数用了 %r,但该函数没有这个变量"
|
|
1863
|
+
% (node.name, vname))
|
|
1864
|
+
else:
|
|
1865
|
+
bad.append("%s(): resolve_style 没传 work(单跑该 stage 会读不到 design.json)"
|
|
1866
|
+
% node.name)
|
|
1867
|
+
assert not bad, ";".join(bad[:4])
|
|
1868
|
+
return "render 里 %d 处 resolve_style 全部带得上 work" % sum(
|
|
1869
|
+
1 for n in ast.walk(tree) if isinstance(n, ast.Call)
|
|
1870
|
+
and (getattr(n.func, "id", None) or getattr(n.func, "attr", None)) == "resolve_style")
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def t_proposal_gate():
|
|
1874
|
+
"""58 提案闸:没确认时必须判 draft/absent,确认后才放行;坏文件判 broken。"""
|
|
1875
|
+
try:
|
|
1876
|
+
import propose as PP
|
|
1877
|
+
except Exception: # noqa: BLE001
|
|
1878
|
+
return "skip(没有 propose.py)"
|
|
1879
|
+
with tempfile.TemporaryDirectory() as work:
|
|
1880
|
+
status, detail = PP.proposal_state(work)
|
|
1881
|
+
assert status == "absent", "空目录应判 absent,收到 %r" % status
|
|
1882
|
+
os.makedirs(work, exist_ok=True)
|
|
1883
|
+
with open(os.path.join(work, "proposal.json"), "w", encoding="utf-8") as fh:
|
|
1884
|
+
json.dump({"schema": 1, "rev": 1, "status": "draft",
|
|
1885
|
+
"candidates": [{"id": "A"}, {"id": "B"}]}, fh)
|
|
1886
|
+
status, _ = PP.proposal_state(work)
|
|
1887
|
+
assert status == "draft", "有草案但没确认应判 draft,收到 %r" % status
|
|
1888
|
+
with open(os.path.join(work, "proposal.json"), "w", encoding="utf-8") as fh:
|
|
1889
|
+
fh.write("{broken json")
|
|
1890
|
+
try:
|
|
1891
|
+
status, _ = PP.proposal_state(work)
|
|
1892
|
+
except SystemExit:
|
|
1893
|
+
status = "broken"
|
|
1894
|
+
assert status == "broken", "坏 JSON 应判 broken,收到 %r" % status
|
|
1895
|
+
return "absent / draft / broken 三档判定正确"
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def t_subtitle_style():
|
|
1901
|
+
"""61 字幕样式可定制(v1.0.2):design.visual.subtitle 必须真的改变像素,缺省逐像素不变。
|
|
1902
|
+
|
|
1903
|
+
为什么必须有这条:字幕字号/位置/背板过去是 filmkit 的**模块常量**,任何两条片子
|
|
1904
|
+
的字幕带都一模一样 —— 这正是用户说的"跨片大面积重复"。改成设计字段后,最容易
|
|
1905
|
+
出现的退化是"字段读进来了但没传给 draw_subtitle"(写完不生效,实测踩过)。
|
|
1906
|
+
所以这里验行为:同一个 theme、同一段文字,换字幕参数必须像素不同;
|
|
1907
|
+
不传参数时必须与 v1.0.1 的调用逐像素一致(老项目不漂)。
|
|
1908
|
+
"""
|
|
1909
|
+
import numpy as _np
|
|
1910
|
+
import designkit as DK
|
|
1911
|
+
# 用一套现场采样设计里的主题,避免依赖内置主题表(v1.0.1 已删)
|
|
1912
|
+
d = DK.sample_designs({"topic": "字幕样式自检"}, n=1, seed=3)[0]
|
|
1913
|
+
plan = DK.design_to_plan(d)
|
|
1914
|
+
theme = plan["theme"]
|
|
1915
|
+
text = "这是一句用来测试字幕样式是否真的生效的中文字幕"
|
|
1916
|
+
base = K.Image.new("RGB", (K.W, K.H), tuple(theme["bg"]))
|
|
1917
|
+
default_img = K.draw_subtitle(base.copy(), text, theme=theme)
|
|
1918
|
+
kw = K.subtitle_kwargs(plan)
|
|
1919
|
+
assert kw.get("size") and kw.get("align") in ("center", "left"), (
|
|
1920
|
+
"subtitle_kwargs 没把设计的字幕参数带出来:%r" % (kw,))
|
|
1921
|
+
styled = K.draw_subtitle(base.copy(), text, theme=theme, **kw)
|
|
1922
|
+
a = _np.asarray(default_img, dtype=_np.int16)
|
|
1923
|
+
b = _np.asarray(styled, dtype=_np.int16)
|
|
1924
|
+
assert int(_np.abs(a - b).max()) > 0, "字幕参数没有改变任何像素(字段写了不生效)"
|
|
1925
|
+
# 缺省必须等价于旧调用(老 design.json 逐像素不变)
|
|
1926
|
+
assert K.subtitle_kwargs({}) == {}, "没有 subtitle 的计划必须返回空 kw(保持旧像素)"
|
|
1927
|
+
assert K.subtitle_kwargs({"subtitle": {}}) == {}, "空 subtitle 也必须是空 kw"
|
|
1928
|
+
return "字幕参数改变像素;缺省等价旧调用(size=%s align=%s)" % (kw.get("size"), kw.get("align"))
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def t_layout_cycle():
|
|
1932
|
+
"""62 片内版式轮换(v1.0.2):layout_cycle 真的让不同章节换骨架,同章必须稳定。
|
|
1933
|
+
|
|
1934
|
+
用户原则 5:同一部片不能"长时间一张版式"。v1.0.1 的 design.visual.layout 是单值,
|
|
1935
|
+
grammar 把它当全片默认 → 8 段片子 8 屏同骨架。这条验三件事:
|
|
1936
|
+
① 有 layout_cycle 时,不同 chapter 拿到不同版式;
|
|
1937
|
+
② 同一个 chapter 内所有段版式一致(不能每句都跳);
|
|
1938
|
+
③ 没有 layout_cycle 时行为与旧版一致(全用 layout_default)。
|
|
1939
|
+
"""
|
|
1940
|
+
import grammar as G
|
|
1941
|
+
scenes = [
|
|
1942
|
+
{"id": "a1", "kind": "body", "chapter": "第一章", "start": 0.0, "end": 4.0, "dur": 4.0},
|
|
1943
|
+
{"id": "a2", "kind": "body", "chapter": "第一章", "start": 4.0, "end": 8.0, "dur": 4.0},
|
|
1944
|
+
{"id": "b1", "kind": "body", "chapter": "第二章", "start": 8.0, "end": 12.0, "dur": 4.0},
|
|
1945
|
+
{"id": "b2", "kind": "body", "chapter": "第二章", "start": 12.0, "end": 16.0, "dur": 4.0},
|
|
1946
|
+
]
|
|
1947
|
+
plan = {"chapter_layout": {}, "layout_default": "left-stack",
|
|
1948
|
+
"layout_cycle": ["left-stack", "center-stack"]}
|
|
1949
|
+
scenes = [dict(s) for s in scenes]
|
|
1950
|
+
G.plan_scenes(scenes, {"lines": []}, plan)
|
|
1951
|
+
got = {s["id"]: s["scene_layout"] for s in scenes}
|
|
1952
|
+
assert got["a1"] == got["a2"], "同一章内版式必须稳定,实际 %s" % (got,)
|
|
1953
|
+
assert got["b1"] == got["b2"], "同一章内版式必须稳定,实际 %s" % (got,)
|
|
1954
|
+
assert got["a1"] != got["b1"], "不同章节应轮换版式,实际两章都是 %s" % (got["a1"],)
|
|
1955
|
+
# 没有 cycle 时回到旧行为
|
|
1956
|
+
scenes2 = [dict(s) for s in scenes]
|
|
1957
|
+
G.plan_scenes(scenes2, {"lines": []}, {"chapter_layout": {}, "layout_default": "split-right"})
|
|
1958
|
+
assert all(s["scene_layout"] == "split-right" for s in scenes2), \
|
|
1959
|
+
"没有 layout_cycle 时必须全用 layout_default(旧行为)"
|
|
1960
|
+
return "两章轮换 %s -> %s;同章稳定;无 cycle 时回落 layout_default" % (got["a1"], got["b1"])
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
def t_brief_constraint_robustness():
|
|
1964
|
+
"""63 带约束的 brief 不许高概率直接失败(v1.0.2)。
|
|
1965
|
+
|
|
1966
|
+
实测过的真缺陷:配色候选去重按 24° 分桶,而 must_have「深蓝」把色相窗口压到 52°,
|
|
1967
|
+
n=3 时约一半概率抛 ValueError;「暖纸色 + 女声」20/20 全失败。用户第一步就撞墙。
|
|
1968
|
+
修法改成"色相距离 + 结构差异"分档后,这里用 20 次独立采样守回归。
|
|
1969
|
+
"""
|
|
1970
|
+
import propose as PP
|
|
1971
|
+
import designkit as DK
|
|
1972
|
+
briefs = ["深蓝", "纪录片,暖纸色,缓慢,女声", "科技发布会,深蓝,要遮幅"]
|
|
1973
|
+
for text in briefs:
|
|
1974
|
+
b = PP._brief_of(text, {"title": "t"})
|
|
1975
|
+
for _ in range(6):
|
|
1976
|
+
try:
|
|
1977
|
+
DK.sample_designs(b, n=3)
|
|
1978
|
+
except ValueError as e:
|
|
1979
|
+
raise AssertionError("brief %r 采样失败:%s" % (text, str(e)[:200])) from e
|
|
1980
|
+
return "3 种带约束 brief × 6 次 = 18 次 n=3 采样全部成功"
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
def t_removed_apis_fail_closed():
|
|
1984
|
+
"""64 已删除的 API 必须"明确报错/非零退出",不许 NameError、假绿或静默 fallback。
|
|
1985
|
+
|
|
1986
|
+
v1.0.1 删内置风格表时留下了一批**死路径**:stylekit 的 --list/--show NameError、
|
|
1987
|
+
--lint 空表 exit 0(假绿)、filmkit 的 resolve_theme/style_plan 吞异常回落到写死的
|
|
1988
|
+
v0.4 主题(静默降级 = 用模板出片)。这三种都必须绝迹。
|
|
1989
|
+
"""
|
|
1990
|
+
import subprocess as _sp
|
|
1991
|
+
import stylekit as ST
|
|
1992
|
+
import filmkit as FK
|
|
1993
|
+
# ① stylekit.resolve_script_style 必须 raise 且带指引
|
|
1994
|
+
try:
|
|
1995
|
+
ST.resolve_script_style({"title": "t", "lines": []})
|
|
1996
|
+
except (ValueError, KeyError, SystemExit) as e:
|
|
1997
|
+
msg = str(getattr(e, "code", None) or e)
|
|
1998
|
+
assert "propose.py" in msg, "报错必须给迁移指引(要提到 propose.py):%r" % msg[:120]
|
|
1999
|
+
else:
|
|
2000
|
+
raise AssertionError("没有设计的剧本竟然解析出了计划")
|
|
2001
|
+
# ② stylekit CLI 三个动作必须非零退出(不许假绿/NameError traceback)
|
|
2002
|
+
for flag in ("--list", "--lint"):
|
|
2003
|
+
p = _sp.run([sys.executable, os.path.join(HERE, "stylekit.py"), flag],
|
|
2004
|
+
capture_output=True, text=True, timeout=60)
|
|
2005
|
+
assert p.returncode == 2, "stylekit.py %s 应 exit 2(能力已移除),实际 %d" % (flag, p.returncode)
|
|
2006
|
+
assert "Traceback" not in (p.stderr or ""), "stylekit.py %s 不该抛 traceback" % flag
|
|
2007
|
+
# ③ filmkit 的静默回落必须改成明确报错
|
|
2008
|
+
for fn in (FK.resolve_theme, FK.style_plan):
|
|
2009
|
+
try:
|
|
2010
|
+
fn({"title": "t", "lines": []})
|
|
2011
|
+
except (ValueError, KeyError) as e:
|
|
2012
|
+
assert "propose" in str(e).lower() or "design" in str(e).lower(), \
|
|
2013
|
+
"%s 的报错要给迁移指引:%r" % (fn.__name__, str(e)[:120])
|
|
2014
|
+
else:
|
|
2015
|
+
raise AssertionError("%s 在无设计时静默返回了兜底主题(模板残留)" % fn.__name__)
|
|
2016
|
+
# ④ designkit 的配色采样示例不许崩(--list-palettes 曾 KeyError: family)
|
|
2017
|
+
p = _sp.run([sys.executable, os.path.join(HERE, "designkit.py"), "--list-palettes", "--json"],
|
|
2018
|
+
capture_output=True, text=True, timeout=120)
|
|
2019
|
+
assert p.returncode == 0, "designkit.py --list-palettes 崩了:%s" % (p.stderr or p.stdout)[:200]
|
|
2020
|
+
return "resolve_script_style/CLI/filmkit 全部 fail-closed;--list-palettes 可跑"
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
def t_audio_overrides():
|
|
2024
|
+
"""65 design.audio 的音量/音效参数必须真的落进 script(v1.0.2)。
|
|
2025
|
+
|
|
2026
|
+
实测过的真缺陷:design.json 的 audio.music.gain_db / duck_db 与 audio.sfx.enabled
|
|
2027
|
+
/ density / gain_db 一直被 validate_design 接受、却从来没有渲染代码读它们 ——
|
|
2028
|
+
用户把 sfx.enabled 写成 false,成片照样出 8 条音效。
|
|
2029
|
+
现在的契约:**film.json 显式写了的 > design 的值 > 模块默认**。
|
|
2030
|
+
"""
|
|
2031
|
+
import designkit as DK
|
|
2032
|
+
d = DK.sample_designs({"topic": "声音参数自检"}, n=1, seed=5)[0]
|
|
2033
|
+
d = json.loads(json.dumps(d))
|
|
2034
|
+
d["audio"]["music"]["gain_db"] = -7.5
|
|
2035
|
+
d["audio"]["music"]["duck_db"] = 9.0
|
|
2036
|
+
d["audio"]["sfx"] = {"enabled": False, "density": "rich", "gain_db": -3}
|
|
2037
|
+
plan = DK.design_to_plan(d)
|
|
2038
|
+
ov = plan.get("audio_overrides") or {}
|
|
2039
|
+
assert abs(float(ov["music"]["gain_db"]) + 7.5) < 1e-9, "music.gain_db 没进计划"
|
|
2040
|
+
assert abs(float(ov["music"]["duck_db"]) - 9.0) < 1e-9, "music.duck_db 没进计划"
|
|
2041
|
+
assert ov["sfx"]["enabled"] is False, "sfx.enabled 没进计划"
|
|
2042
|
+
import render as R
|
|
2043
|
+
# 剧本里没写 -> 用 design 的值
|
|
2044
|
+
s1 = {"title": "t", "lines": []}
|
|
2045
|
+
R._apply_audio_overrides(s1, plan)
|
|
2046
|
+
assert s1["sfx"]["enabled"] is False and abs(s1["music"]["gain_db"] + 7.5) < 1e-9, \
|
|
2047
|
+
"design 的声音参数没有填进 script:%r" % (s1,)
|
|
2048
|
+
# 剧本显式写了 -> 以剧本为准(用户手里 film.json 是最终裁决权)
|
|
2049
|
+
s2 = {"title": "t", "lines": [], "music": {"gain_db": -1.0}, "sfx": {"enabled": True}}
|
|
2050
|
+
R._apply_audio_overrides(s2, plan)
|
|
2051
|
+
assert s2["music"]["gain_db"] == -1.0 and s2["sfx"]["enabled"] is True, \
|
|
2052
|
+
"film.json 的显式值被 design 覆盖了:%r" % (s2,)
|
|
2053
|
+
return "design.audio -> plan -> script 贯通;film.json 显式值优先"
|
|
2054
|
+
|
|
2055
|
+
|
|
2056
|
+
def t_design_ledger():
|
|
2057
|
+
"""66 片间去重台账(v1.0.2):确认过的设计要记住,下次提案主动避开;可关可查。
|
|
2058
|
+
|
|
2059
|
+
用户原则 5:不同视频的整体风格不能大面积重复。v1.0.1 只保证"同一 brief 两次采样不同"
|
|
2060
|
+
(nonce),但没有记忆——连拍同题材仍可能撞到相近设计。台账补上这一环。
|
|
2061
|
+
这条验四件事:① confirm 后写台账;② 提案会读台账并报告距离;
|
|
2062
|
+
③ 台账路径可被环境变量重定向(绝不污染用户真实台账);④ --no-ledger 可关闭。
|
|
2063
|
+
"""
|
|
2064
|
+
import tempfile
|
|
2065
|
+
import subprocess as _sp
|
|
2066
|
+
tmp = tempfile.mkdtemp(prefix="vs_ledger_selfcheck_")
|
|
2067
|
+
ledger = os.path.join(tmp, "ledger.json")
|
|
2068
|
+
env = dict(os.environ, PYTHONIOENCODING="utf-8", VIDEO_STUDIO_LEDGER=ledger)
|
|
2069
|
+
script = {"title": "台账自检", "lines": [
|
|
2070
|
+
{"id": "l01", "kind": "hook", "text": "第一句话。"},
|
|
2071
|
+
{"id": "l02", "kind": "body", "text": "第二句话,讲一件具体的事。"},
|
|
2072
|
+
{"id": "l03", "kind": "outro", "text": "结束。"}]}
|
|
2073
|
+
film = os.path.join(tmp, "film.json")
|
|
2074
|
+
with open(film, "w", encoding="utf-8") as fh:
|
|
2075
|
+
json.dump(script, fh, ensure_ascii=False)
|
|
2076
|
+
pp = os.path.join(HERE, "propose.py")
|
|
2077
|
+
|
|
2078
|
+
def _run(args):
|
|
2079
|
+
r = _sp.run([sys.executable, pp] + args, cwd=tmp, capture_output=True,
|
|
2080
|
+
text=True, encoding="utf-8", errors="replace", env=env, timeout=300)
|
|
2081
|
+
assert r.returncode == 0, "propose.py %s 失败:%s" % (args[0], (r.stderr or r.stdout)[-300:])
|
|
2082
|
+
return r
|
|
2083
|
+
|
|
2084
|
+
_run(["--brief", "深蓝科技解说", "--script", film, "--n", "3"])
|
|
2085
|
+
assert not os.path.exists(ledger), "还没 confirm 就写台账了(草稿不该记)"
|
|
2086
|
+
_run(["--confirm", "--candidate", "A"])
|
|
2087
|
+
assert os.path.exists(ledger), "confirm 之后没有写台账:%s" % ledger
|
|
2088
|
+
data = json.load(open(ledger, encoding="utf-8"))
|
|
2089
|
+
assert len(data.get("designs") or []) == 1, "台账条数不对:%r" % (data,)
|
|
2090
|
+
row = data["designs"][0]
|
|
2091
|
+
assert row.get("fingerprint") and isinstance(row.get("design"), dict), "台账记录缺字段"
|
|
2092
|
+
# 第二次提案:必须能读到台账(人读输出里有一行"片间去重")
|
|
2093
|
+
r = _run(["--brief", "深蓝科技解说", "--script", film, "--n", "3"])
|
|
2094
|
+
assert "片间去重" in r.stdout, "提案没有报告台账状态:%s" % r.stdout[-400:]
|
|
2095
|
+
# --show-ledger 能查;--no-ledger 能关
|
|
2096
|
+
r = _run(["--show-ledger"])
|
|
2097
|
+
assert row["fingerprint"] in r.stdout, "--show-ledger 没列出刚记的指纹"
|
|
2098
|
+
r = _run(["--brief", "深蓝科技解说", "--script", film, "--n", "3", "--no-ledger"])
|
|
2099
|
+
assert "--no-ledger" in r.stdout, "--no-ledger 没有给出关闭提示"
|
|
2100
|
+
return "草稿不记 / confirm 记账 / 提案读取并报告 / --show-ledger 可查 / --no-ledger 可关"
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
def t_duration_target():
|
|
2104
|
+
"""67 时长要求真的被接住(v1.0.2):brief 里的「2 分钟」要影响设计,渲染时对账。
|
|
2105
|
+
|
|
2106
|
+
过去 `_brief_of` 把 duration_target 恒填 None —— 用户写「做个 2 分钟的片」等于没写,
|
|
2107
|
+
成片永远按台词长度出(实测 23s / 30s)。这条守三件事:
|
|
2108
|
+
① 中文/阿拉伯数字的常见时长说法都能解析;
|
|
2109
|
+
② 解析出的时长进 design.structure.target_sec,并决定候选段落数;
|
|
2110
|
+
③ 渲染时按"目标 vs 实测"对账(差 >15% 告警,但不截断旁白)。
|
|
2111
|
+
"""
|
|
2112
|
+
import propose as PP
|
|
2113
|
+
import designkit as DK
|
|
2114
|
+
cases = {"2分钟": 120.0, "做个90秒的短视频": 90.0, "一分钟半": 90.0,
|
|
2115
|
+
"3分30秒": 210.0, "2.5分钟": 150.0, "两分钟": 120.0}
|
|
2116
|
+
for text, want in cases.items():
|
|
2117
|
+
got = PP.parse_duration(text)
|
|
2118
|
+
assert got is not None and abs(got - want) < 1e-6, \
|
|
2119
|
+
"时长解析 %r 得到 %r,期望 %r" % (text, got, want)
|
|
2120
|
+
assert PP.parse_duration("深蓝科技解说") is None, "没有时长时不该硬凑"
|
|
2121
|
+
assert PP.parse_duration("两小时") is None, "超出 3–1800s 契约范围应拒绝"
|
|
2122
|
+
b = PP._brief_of("深蓝,科技,2分钟,硬切", {"title": "t"})
|
|
2123
|
+
assert b.get("duration_target") == 120.0, "brief 没接住时长:%r" % (b,)
|
|
2124
|
+
d = DK.sample_designs(b, n=2, seed=9)[0]
|
|
2125
|
+
assert d["structure"].get("target_sec") == 120.0, \
|
|
2126
|
+
"目标时长没进设计:%r" % (d["structure"],)
|
|
2127
|
+
plan = DK.design_to_plan(d)
|
|
2128
|
+
assert plan.get("target_sec") == 120.0, "目标时长没进计划:%r" % (plan.get("target_sec"),)
|
|
2129
|
+
return "6 种时长说法解析正确;进 design/plan;超范围拒绝"
|
|
1410
2130
|
CASES = [
|
|
1411
2131
|
("1 TTS 缓存校验文本", t_cache_key),
|
|
1412
2132
|
("2 sub_window 覆盖长句", t_sub_window),
|
|
@@ -1459,6 +2179,22 @@ CASES = [
|
|
|
1459
2179
|
("49 TTS 后端可替换(接口契约)", t_tts_backend),
|
|
1460
2180
|
("50 web 风格载荷(legacy 必须回落)", t_web_style_payload),
|
|
1461
2181
|
("51 金帧回归(像素基线)", t_golden_frames),
|
|
2182
|
+
("52 场景语法(10 类分流/确定/段长门)", t_scene_grammar),
|
|
2183
|
+
("53 视觉语言包(结构签名互不相同)", t_visual_languages),
|
|
2184
|
+
("54 场景类型单真值(4 处集合一致)", t_scene_types_single_truth),
|
|
2185
|
+
("55 剪影可区分(G13:换风格不能只是换色)", t_silhouette_gate),
|
|
2186
|
+
("56 设计 DSL(采样/校验/转计划/fail-closed)", t_design_dsl),
|
|
2187
|
+
("57 零模板(仓库里不许有预设;采样确定性且随 brief 变化)", t_no_templates),
|
|
2188
|
+
("58 提案闸(absent/draft/confirmed/broken)", t_proposal_gate),
|
|
2189
|
+
("59 逐 stage 可单独跑(resolve_style 必须拿得到 work)", t_stage_standalone),
|
|
2190
|
+
("60 brief 里的要求真的生效(自然语言 -> 约束 -> 采样)", t_brief_constraints),
|
|
2191
|
+
("61 字幕样式可定制(design.visual.subtitle 真的生效)", t_subtitle_style),
|
|
2192
|
+
("62 片内版式轮换(layout_cycle:换章即换骨架)", t_layout_cycle),
|
|
2193
|
+
("63 带约束的 brief 不会高概率采样失败", t_brief_constraint_robustness),
|
|
2194
|
+
("64 已删除 API 的 fail-closed(不许 NameError / 假绿 / 静默回落)", t_removed_apis_fail_closed),
|
|
2195
|
+
("65 design.audio 的音量/音效参数真的落进 script", t_audio_overrides),
|
|
2196
|
+
("66 片间去重台账(确认记账 / 提案避开 / 可关可查)", t_design_ledger),
|
|
2197
|
+
("67 时长要求真的被接住(brief -> design -> 渲染对账)", t_duration_target),
|
|
1462
2198
|
]
|
|
1463
2199
|
|
|
1464
2200
|
|