修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)
一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。
二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。
三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
This commit is contained in:
1 parent
e03465c398
commit
237a09a5b0
161 files changed
+19429
-2
No files matched your search
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"compile_scroll_video",
|
||||
SCRIPT_DIR / "compile_scroll_video.py",
|
||||
)
|
||||
assert SPEC and SPEC.loader
|
||||
COMPILE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = COMPILE
|
||||
SPEC.loader.exec_module(COMPILE)
|
||||
|
||||
from chroma_key import analyze_frame, default_parameters, key_image
|
||||
|
||||
|
||||
class ChromaVideoCompileTests(unittest.TestCase):
|
||||
def test_compiler_requires_explicit_background_owner(self) -> None:
|
||||
with self.assertRaises(SystemExit):
|
||||
COMPILE.parser().parse_args(
|
||||
["source.mp4", "build", "--budget-report", "budget.json"]
|
||||
)
|
||||
|
||||
def test_compiler_requires_frame_policy_and_timeline_output(self) -> None:
|
||||
with self.assertRaises(SystemExit):
|
||||
COMPILE.parser().parse_args(
|
||||
[
|
||||
"source.mp4",
|
||||
"build",
|
||||
"--background-owner",
|
||||
"video",
|
||||
"--budget-report",
|
||||
"budget.json",
|
||||
]
|
||||
)
|
||||
|
||||
def test_timeline_keeps_hold_separate_from_exclusive_end(self) -> None:
|
||||
specs = COMPILE.parse_segment_specs(
|
||||
["first=0:2:3", "second=3:5:6"]
|
||||
)
|
||||
|
||||
timeline = COMPILE.build_timeline(
|
||||
specs,
|
||||
[0, 1, 2, 4, 5, 6],
|
||||
7,
|
||||
24,
|
||||
{"type": "constant", "rate": 1.0},
|
||||
)
|
||||
|
||||
first = timeline["segments"][0]
|
||||
self.assertEqual(timeline["initialState"], "state-0")
|
||||
self.assertEqual(
|
||||
[state["id"] for state in timeline["states"]],
|
||||
["state-0", "first", "second"],
|
||||
)
|
||||
self.assertEqual(first["from"], "state-0")
|
||||
self.assertEqual(first["to"], "first")
|
||||
self.assertEqual(first["frames"]["hold"], 2)
|
||||
self.assertEqual(first["frames"]["endExclusive"], 3)
|
||||
self.assertLess(first["hold"], first["endExclusive"])
|
||||
|
||||
def test_representative_frames_include_both_ends(self) -> None:
|
||||
paths = [Path(f"frame_{index:05d}.png") for index in range(100)]
|
||||
|
||||
sampled = COMPILE.representative_frames(paths)
|
||||
|
||||
self.assertEqual(len(sampled), 48)
|
||||
self.assertEqual(sampled[0], paths[0])
|
||||
self.assertEqual(sampled[-1], paths[-1])
|
||||
|
||||
def test_representative_indices_include_both_ends(self) -> None:
|
||||
sampled = COMPILE.representative_indices(323)
|
||||
|
||||
self.assertEqual(len(sampled), 48)
|
||||
self.assertEqual(sampled[0], 0)
|
||||
self.assertEqual(sampled[-1], 322)
|
||||
|
||||
def test_uniform_green_frames_pass_key_validation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths: list[Path] = []
|
||||
for index in range(3):
|
||||
path = Path(directory) / f"frame_{index:05d}.png"
|
||||
image = Image.new("RGB", (32, 32), (0, 255, 0))
|
||||
image.paste((255, 0, 0), (8, 8, 24, 24))
|
||||
image.save(path)
|
||||
paths.append(path)
|
||||
|
||||
result = COMPILE.validate_key_source(paths, (0, 255, 0))
|
||||
|
||||
self.assertEqual(result["kind"], "green")
|
||||
self.assertEqual(result["borderSpreadP95Max"], 0)
|
||||
|
||||
def test_non_chroma_background_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "frame.png"
|
||||
Image.new("RGB", (32, 32), (255, 255, 255)).save(path)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "绿色或洋红色键背景"):
|
||||
COMPILE.validate_key_source([path], (255, 255, 255))
|
||||
|
||||
def test_dark_green_region_and_green_edge_are_removed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory) / "source.png"
|
||||
output = Path(directory) / "output.png"
|
||||
image = Image.new("RGB", (64, 64), (0, 235, 10))
|
||||
image.paste((18, 82, 24), (8, 8, 56, 56))
|
||||
image.paste((220, 40, 30), (24, 24, 40, 40))
|
||||
image.save(source)
|
||||
parameters = default_parameters((0, 235, 10))
|
||||
|
||||
key_image(source, output, parameters)
|
||||
|
||||
with Image.open(output) as result:
|
||||
alpha = result.getchannel("A")
|
||||
self.assertLessEqual(alpha.getpixel((12, 12)), 2)
|
||||
self.assertGreaterEqual(alpha.getpixel((32, 32)), 250)
|
||||
metrics = analyze_frame(source, parameters)
|
||||
self.assertLessEqual(metrics["keyLikeAlphaP99"], 0.01)
|
||||
|
||||
def test_magenta_key_mode_removes_magenta_background(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory) / "source.png"
|
||||
output = Path(directory) / "output.png"
|
||||
image = Image.new("RGB", (32, 32), (240, 0, 235))
|
||||
image.paste((30, 180, 220), (8, 8, 24, 24))
|
||||
image.save(source)
|
||||
parameters = default_parameters((240, 0, 235))
|
||||
|
||||
key_image(source, output, parameters)
|
||||
|
||||
self.assertEqual(parameters.mode, "magenta")
|
||||
with Image.open(output) as result:
|
||||
alpha = result.getchannel("A")
|
||||
self.assertLessEqual(alpha.getpixel((2, 2)), 2)
|
||||
self.assertGreaterEqual(alpha.getpixel((16, 16)), 250)
|
||||
|
||||
def test_source_anchor_maps_to_nearest_retained_frame(self) -> None:
|
||||
kept = [0, 1, 4, 9, 20, 48, 90, 248, 251]
|
||||
|
||||
self.assertEqual(COMPILE.map_source_frame(248, kept), 7)
|
||||
self.assertEqual(COMPILE.map_source_frame(47, kept), 5)
|
||||
|
||||
def test_anchor_parser_rejects_duplicates(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "重复"):
|
||||
COMPILE.parse_anchors(["center=20", "center=21"])
|
||||
|
||||
def test_runtime_shader_implements_manifest_contract(self) -> None:
|
||||
shader = (
|
||||
SCRIPT_DIR.parent / "assets" / "chroma-video-renderer.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
for token in (
|
||||
'algorithm: "dominance-v2"',
|
||||
"uDominanceStart",
|
||||
"uDominanceEnd",
|
||||
"uSpillStart",
|
||||
"uSpillEnd",
|
||||
"keying.keyColor",
|
||||
):
|
||||
self.assertIn(token, shader)
|
||||
|
||||
def test_video_compiler_rejects_atlas_budget_report(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "budget.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"passes": True,
|
||||
"delivery": {"selected": "alpha-atlas"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "没有选择 chroma-video"):
|
||||
COMPILE.load_budget_report(path)
|
||||
|
||||
def test_baked_compiler_accepts_baked_budget_report(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "budget.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"passes": True,
|
||||
"delivery": {"selected": "baked-video"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = COMPILE.load_budget_report(path, "baked-video")
|
||||
|
||||
self.assertEqual(report["delivery"]["selected"], "baked-video")
|
||||
with self.assertRaisesRegex(ValueError, "没有选择 chroma-video"):
|
||||
COMPILE.load_budget_report(path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,29 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from contextlib import redirect_stdout
|
||||
sys.path.insert(0,str(Path(__file__).parents[1]/'scripts'))
|
||||
import oil_motion_config as config
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def test_main_replaces_legacy_secret_with_reference(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path=Path(tmp)/'config.json';path.write_text(json.dumps({'zenmux':{'api_key':'old-fake','model':'keep'}}))
|
||||
with patch.dict(os.environ,{'OIL_MOTION_CONFIG_FILE':str(path)}),patch.object(sys,'argv',['config','set']),patch.object(config.getpass,'getpass',return_value='new-fake'),patch.object(config.secure_credentials,'save') as save,redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(config.main(),0)
|
||||
save.assert_called_once_with(config.CREDENTIAL_REF,'new-fake')
|
||||
data=json.loads(path.read_text());self.assertEqual(data['zenmux']['model'],'keep')
|
||||
self.assertEqual(data['zenmux']['credential_ref'],config.CREDENTIAL_REF)
|
||||
self.assertNotIn('fake',path.read_text())
|
||||
def test_store_failure_keeps_original_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path=Path(tmp)/'config.json';path.write_text('{"zenmux":{"model":"keep"}}')
|
||||
before=path.read_text()
|
||||
with patch.object(config.getpass,'getpass',return_value='new-fake'),patch.object(config.secure_credentials,'save',side_effect=RuntimeError('unavailable')),self.assertRaises(RuntimeError):
|
||||
config.set_api_key(path)
|
||||
self.assertEqual(path.read_text(),before)
|
||||
if __name__ == '__main__':unittest.main()
|
||||
@@ -0,0 +1,393 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_PATHS = [ROOT / "SKILL.md", *sorted((ROOT / "references").glob("*.md"))]
|
||||
|
||||
|
||||
def read(relative: str) -> str:
|
||||
# 文档按行宽换行,断言基于去掉换行后的连续文本
|
||||
return (ROOT / relative).read_text(encoding="utf-8").replace("\n", "")
|
||||
|
||||
|
||||
def bash_blocks(path: Path) -> list[str]:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
return re.findall(r"^```bash\n(.*?)^```", source, re.S | re.M)
|
||||
|
||||
|
||||
def files_with_bash(snippet: str) -> list[str]:
|
||||
return sorted(
|
||||
path.name
|
||||
for path in DOC_PATHS
|
||||
if any(snippet in block for block in bash_blocks(path))
|
||||
)
|
||||
|
||||
|
||||
class DocsConsistencyTests(unittest.TestCase):
|
||||
"""文档架构回归:唯一事实源、链接有效、关键语义与脚本能力。
|
||||
|
||||
每个语义只在它的事实源文件中断言一次,不依赖跨文件重复的固定句。
|
||||
"""
|
||||
|
||||
# ---------- SKILL.md:主流程与关键防回归语义 ----------
|
||||
|
||||
def test_skill_defines_concept_contract_gate(self) -> None:
|
||||
skill = read("SKILL.md")
|
||||
self.assertIn("Concept Contract", skill)
|
||||
self.assertIn("subject_count", skill)
|
||||
self.assertIn("background_owner", skill)
|
||||
self.assertIn("input_semantics", skill)
|
||||
self.assertIn("time_control", skill)
|
||||
self.assertIn("navigation", skill)
|
||||
self.assertIn("clip_continuity", skill)
|
||||
self.assertIn("不改写、不扩写", skill)
|
||||
|
||||
def test_key_anti_regression_semantics_have_canonical_homes(self) -> None:
|
||||
skill = read("SKILL.md")
|
||||
qa = read("references/qa.md")
|
||||
chroma = read("references/chroma-video.md")
|
||||
# 场景背景默认与视频一起生成(baked 默认)
|
||||
self.assertIn("baked-video", skill)
|
||||
self.assertIn("background_owner: video", skill)
|
||||
# 只有明确透明复用才用 chroma
|
||||
self.assertIn("主体必须透明复用在页面背景", skill)
|
||||
# 多段同脸与实际尾帧接力
|
||||
self.assertIn("同脸", qa)
|
||||
self.assertIn("实际尾帧", qa)
|
||||
# Pilot 通过后才量产
|
||||
self.assertIn("Pilot 硬门", qa)
|
||||
self.assertIn("approve-pilot", qa)
|
||||
# 坏绿幕直接拒收
|
||||
self.assertIn("拒收母版", chroma)
|
||||
|
||||
def test_motion_brief_does_not_duplicate_contract_fields(self) -> None:
|
||||
source = (ROOT / "SKILL.md").read_text(encoding="utf-8")
|
||||
section = source.split("建立生产计划", 1)[1].split("### 3.", 1)[0]
|
||||
block = re.search(r"```yaml\n(.*?)```", section, re.S)
|
||||
self.assertIsNotNone(block, "Motion Brief 缺少 yaml 模板")
|
||||
brief = block.group(1)
|
||||
top_level_fields = set(re.findall(r"^([a-z_]+):", brief, re.M))
|
||||
for field in (
|
||||
"subject_count",
|
||||
"background_owner",
|
||||
"clip_continuity",
|
||||
"driver",
|
||||
"input_semantics",
|
||||
"time_control",
|
||||
"navigation",
|
||||
"scene",
|
||||
"destination",
|
||||
"aspect_ratio",
|
||||
):
|
||||
self.assertNotIn(
|
||||
field, top_level_fields, f"Motion Brief 复制了合同字段 {field}"
|
||||
)
|
||||
|
||||
def test_skill_stays_within_line_budget(self) -> None:
|
||||
lines = (ROOT / "SKILL.md").read_text(encoding="utf-8").splitlines()
|
||||
self.assertLessEqual(len(lines), 200)
|
||||
|
||||
def test_skill_has_no_parallel_principles_flow(self) -> None:
|
||||
source = (ROOT / "SKILL.md").read_text(encoding="utf-8")
|
||||
self.assertNotIn("## 核心原则", source)
|
||||
self.assertEqual(source.count("## 主流程"), 1)
|
||||
|
||||
def test_skill_separates_input_navigation_and_time_control(self) -> None:
|
||||
skill = read("SKILL.md")
|
||||
runtime = read("references/runtime.md")
|
||||
self.assertIn("格式选择与播放方式是两件事", skill)
|
||||
self.assertIn("分页页面也可以使用连续时间轴", skill)
|
||||
self.assertIn("滚动既可以 scrub,也可以触发片段播放", runtime)
|
||||
self.assertIn("start <= hold < endExclusive", runtime)
|
||||
self.assertIn("one-gesture-one-step", runtime)
|
||||
self.assertIn("programmatic_navigation=ignore", runtime)
|
||||
self.assertIn("timeline.json.states[].id", runtime)
|
||||
|
||||
def test_skill_no_longer_mandates_chroma_for_everything(self) -> None:
|
||||
skill = read("SKILL.md")
|
||||
self.assertNotIn("固定绿幕管线", skill)
|
||||
self.assertNotIn("不存在“直接生成最终场景背景”的分支", skill)
|
||||
# 旧 Motion Brief 无条件要求 matte,新模板按背景归属分路线
|
||||
self.assertNotIn("matte_delivery: required", skill)
|
||||
|
||||
def test_skill_routes_to_every_reference(self) -> None:
|
||||
skill = read("SKILL.md")
|
||||
for ref in sorted((ROOT / "references").glob("*.md")):
|
||||
self.assertIn(
|
||||
f"references/{ref.name}", skill, f"SKILL.md 没有路由到 {ref.name}"
|
||||
)
|
||||
|
||||
# ---------- 唯一事实源:命令示例只存在于一个 canonical reference ----------
|
||||
|
||||
def test_command_examples_have_single_canonical_home(self) -> None:
|
||||
expectations = {
|
||||
"motion_budget.py": ["delivery-selection.md"],
|
||||
"video_job.py": ["prompting.md"],
|
||||
"image_job.py": ["prompting.md"],
|
||||
"composite_alpha_keyframe.py": ["prompting.md"],
|
||||
"compose_travel_frames.py": ["prompting.md"],
|
||||
"approve-pilot": ["qa.md"],
|
||||
"verify-chain": ["qa.md"],
|
||||
"compile_scroll_video.py": ["baked-video.md", "chroma-video.md"],
|
||||
"optimize_motion.py": ["optimization.md"],
|
||||
"loop_cleanup.py": ["alpha-atlas.md"],
|
||||
"motion_pipeline.py": ["alpha-atlas.md"],
|
||||
"create_explainer.py": ["explainer.md"],
|
||||
"oil_motion_config.py": [], # 原终端入口不再作为默认命令示例
|
||||
}
|
||||
for snippet, expected in expectations.items():
|
||||
self.assertEqual(
|
||||
files_with_bash(snippet),
|
||||
expected,
|
||||
f"{snippet} 的命令示例不在唯一事实源 {expected}",
|
||||
)
|
||||
|
||||
def test_page_setup_and_real_video_submission_share_profile(self) -> None:
|
||||
skill = read("SKILL.md")
|
||||
self.assertIn("profile.ts\" setup default", skill)
|
||||
self.assertIn("profile.ts\" status default", skill)
|
||||
prompting = read("references/prompting.md")
|
||||
for script in ("image_job.py", "video_job.py"):
|
||||
self.assertIn(f"profile.ts\" run default -- python3 \"$OIL_MOTION/scripts/{script}\"", prompting)
|
||||
|
||||
def test_keyframes_use_bundled_generator_not_host_tool(self) -> None:
|
||||
for path in [ROOT / "SKILL.md", *sorted((ROOT / "references").glob("*.md"))]:
|
||||
self.assertNotIn("$imagegen", path.read_text(encoding="utf-8"), f"{path.name} 依赖宿主专属生图工具")
|
||||
skill = read("SKILL.md")
|
||||
self.assertIn("--background transparent", skill)
|
||||
self.assertIn("--background opaque", skill)
|
||||
self.assertNotIn("1792x1024", read("references/prompting.md"))
|
||||
|
||||
def test_canonical_sections_not_duplicated(self) -> None:
|
||||
docs = {path.name: path.read_text(encoding="utf-8") for path in DOC_PATHS}
|
||||
for marker, home in (
|
||||
("固定决策顺序", "delivery-selection.md"),
|
||||
("逐帧模拟运行时抠色", "chroma-video.md"),
|
||||
("postEncodeKeyingPassed", "chroma-video.md"),
|
||||
("扩大抠色阈值", "chroma-video.md"),
|
||||
("真实页面最终位置挂载", "qa.md"),
|
||||
("不接受“差不多”", "qa.md"),
|
||||
("不允许误差逐段累积", "qa.md"),
|
||||
("接口错误 `2013`", "prompting.md"),
|
||||
):
|
||||
homes = sorted(name for name, text in docs.items() if marker in text)
|
||||
self.assertEqual(homes, [home], f"{marker} 应只属于 {home}")
|
||||
|
||||
def test_no_exact_duplicate_prose_paragraphs(self) -> None:
|
||||
seen: dict[str, str] = {}
|
||||
for path in DOC_PATHS:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for paragraph in re.split(r"\n\s*\n", source):
|
||||
normalized = re.sub(r"\s+", " ", paragraph).strip()
|
||||
if len(normalized) < 120:
|
||||
continue
|
||||
previous = seen.get(normalized)
|
||||
self.assertIsNone(
|
||||
previous,
|
||||
f"{path.name} 与 {previous} 存在完全重复段落",
|
||||
)
|
||||
seen[normalized] = path.name
|
||||
|
||||
# ---------- 职责边界:各文档不复述其他事实源的段落 ----------
|
||||
|
||||
def test_prompting_keeps_params_but_not_gate_algorithms(self) -> None:
|
||||
doc = read("references/prompting.md")
|
||||
# 提交参数是 prompting 的职责
|
||||
self.assertIn("--loop-frame", doc)
|
||||
self.assertIn("--seed", doc)
|
||||
self.assertIn("--continuity-mode chain", doc)
|
||||
# 公共工作流与 Pilot/帧链算法不复述,只链接
|
||||
self.assertNotIn("固定工作流", doc)
|
||||
self.assertNotIn("在联网前阻断", doc)
|
||||
self.assertNotIn("SHA-256 完全一致", doc)
|
||||
|
||||
def test_minimax_keeps_only_atlas_specifics(self) -> None:
|
||||
doc = read("references/alpha-atlas.md")
|
||||
# 图集路线专属命令仍在
|
||||
self.assertIn("loop_cleanup.py", doc)
|
||||
self.assertIn("atlas", doc)
|
||||
# 首尾模式表与公共提交/验收复述已删除
|
||||
self.assertNotIn("仅参考生成", doc)
|
||||
self.assertNotIn("母版门槛", doc)
|
||||
self.assertNotIn("video_job.py", doc)
|
||||
# 公共步骤只链接,不复述清单
|
||||
self.assertNotIn("完整观看视频", doc)
|
||||
|
||||
def test_skill_flow_steps_link_instead_of_repeating(self) -> None:
|
||||
skill = read("SKILL.md")
|
||||
# Pilot 四项清单、帧链算法、抠色算法只存在于 qa / chroma-video
|
||||
self.assertNotIn("真实页面最终位置挂载", skill)
|
||||
self.assertNotIn("qa/frame-chain.json` 证明", skill)
|
||||
self.assertNotIn("similarity", skill)
|
||||
self.assertNotIn("smoothDamp", skill)
|
||||
self.assertNotIn("同脸、同发型", skill)
|
||||
self.assertNotIn("拒收母版", skill)
|
||||
self.assertNotIn("SHA-256 完全一致", skill)
|
||||
self.assertNotIn("48 FPS", skill)
|
||||
|
||||
# ---------- 链接有效 ----------
|
||||
|
||||
def test_markdown_links_resolve(self) -> None:
|
||||
for path in DOC_PATHS:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for target in re.findall(r"\]\(([^)]+)\)", source):
|
||||
if target.startswith(("http://", "https://", "#", "mailto:")):
|
||||
continue
|
||||
target = target.split("#", 1)[0]
|
||||
resolved = (path.parent / target).resolve()
|
||||
self.assertTrue(
|
||||
resolved.exists(), f"{path.name} 的链接失效: {target}"
|
||||
)
|
||||
|
||||
def test_no_reference_is_orphaned(self) -> None:
|
||||
for ref in sorted((ROOT / "references").glob("*.md")):
|
||||
others = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in DOC_PATHS
|
||||
if path != ref
|
||||
)
|
||||
self.assertIn(ref.name, others, f"{ref.name} 没有被任何文档链接")
|
||||
|
||||
# ---------- 各 canonical reference 的关键语义 ----------
|
||||
|
||||
def test_delivery_selection_owns_route_selection(self) -> None:
|
||||
doc = read("references/delivery-selection.md")
|
||||
self.assertIn("baked-video", doc)
|
||||
self.assertIn("--background-owner", doc)
|
||||
self.assertIn("delivery.selected", doc)
|
||||
self.assertIn("固定决策顺序", doc)
|
||||
self.assertIn("runtime.controller", doc)
|
||||
|
||||
def test_chroma_route_owns_reject_first_matte_gate(self) -> None:
|
||||
doc = read("references/chroma-video.md")
|
||||
self.assertIn("逐帧模拟运行时抠色", doc)
|
||||
self.assertIn("runtime.keying", doc)
|
||||
self.assertIn("溢色", doc)
|
||||
self.assertIn("拒收", doc)
|
||||
self.assertIn("禁止靠扩大抠色阈值", doc)
|
||||
self.assertIn("background-matrix", doc)
|
||||
|
||||
def test_baked_route_reference(self) -> None:
|
||||
doc = read("references/baked-video.md")
|
||||
self.assertIn("background_owner: video", doc)
|
||||
self.assertIn("不做抠色", doc)
|
||||
|
||||
def test_prompting_owns_submission_and_prompt_sections(self) -> None:
|
||||
doc = read("references/prompting.md")
|
||||
self.assertIn("Concept Contract", doc)
|
||||
self.assertIn("Identity Bible", doc)
|
||||
self.assertIn("场景背景段(baked 路线)", doc)
|
||||
self.assertIn("视频色键段(page 路线)", doc)
|
||||
self.assertIn("直接生成真实透明背景 PNG", doc)
|
||||
self.assertIn("composite_alpha_keyframe.py", doc)
|
||||
self.assertIn("--stage", doc)
|
||||
self.assertIn("--continuity-mode chain", doc)
|
||||
|
||||
def test_qa_owns_gates_and_acceptance(self) -> None:
|
||||
doc = read("references/qa.md")
|
||||
self.assertIn("Concept Contract 回归", doc)
|
||||
self.assertIn("Identity Bible", doc)
|
||||
self.assertIn("连续帧链", doc)
|
||||
self.assertIn("Pilot 硬门", doc)
|
||||
self.assertIn("frame-chain.json", doc)
|
||||
self.assertIn("pilot/approval.json", doc)
|
||||
self.assertIn("verify-output-chain", doc)
|
||||
|
||||
# ---------- 脚本能力与文档一致 ----------
|
||||
|
||||
def test_production_gates_are_executable(self) -> None:
|
||||
qa = read("references/qa.md")
|
||||
script = read("scripts/production_gate.py")
|
||||
video_job = read("scripts/video_job.py")
|
||||
budget = read("scripts/motion_budget.py")
|
||||
compiler = read("scripts/compile_scroll_video.py")
|
||||
self.assertIn("pilot/approval.json", qa)
|
||||
self.assertIn("qa/frame-chain.json", qa)
|
||||
self.assertIn("approve-pilot", script)
|
||||
self.assertIn("verify-chain", script)
|
||||
self.assertIn("verify-output-chain", script)
|
||||
self.assertIn("contract_continuity_mode", script)
|
||||
self.assertIn("--stage", video_job)
|
||||
self.assertIn("validate_pilot_approval", video_job)
|
||||
self.assertIn("--background-owner", budget)
|
||||
self.assertIn("--background-owner", compiler)
|
||||
self.assertIn("postEncodeKeyingReport", compiler)
|
||||
self.assertIn("checkedFrames", compiler)
|
||||
|
||||
def test_runtime_assets_cover_both_time_control_modes(self) -> None:
|
||||
interactive = read("assets/interactive-motion.ts")
|
||||
chroma = read("assets/chroma-video-renderer.ts")
|
||||
gesture = read("assets/step-gesture.ts")
|
||||
self.assertIn("createFrameAnimator", interactive)
|
||||
self.assertIn("createSegmentPlayer", interactive)
|
||||
self.assertIn("start <= hold < endExclusive", interactive)
|
||||
self.assertNotIn("timeupdate", interactive)
|
||||
self.assertIn("startLive", chroma)
|
||||
self.assertIn("stopLive", chroma)
|
||||
self.assertIn("createStepGestureAdapter", gesture)
|
||||
self.assertIn("setProgrammaticNavigation", gesture)
|
||||
|
||||
def test_command_examples_do_not_omit_hard_gate_arguments(self) -> None:
|
||||
for path in DOC_PATHS:
|
||||
for block in bash_blocks(path):
|
||||
if "motion_budget.py" in block:
|
||||
self.assertIn(
|
||||
"--background-owner",
|
||||
block,
|
||||
f"{path.name} 的预算命令漏传背景归属",
|
||||
)
|
||||
self.assertIn(
|
||||
"--time-control",
|
||||
block,
|
||||
f"{path.name} 的预算命令漏传时间控制",
|
||||
)
|
||||
if "compile_scroll_video.py" in block:
|
||||
self.assertIn(
|
||||
"--background-owner",
|
||||
block,
|
||||
f"{path.name} 的编译命令漏传背景归属",
|
||||
)
|
||||
self.assertIn(
|
||||
"--frame-policy",
|
||||
block,
|
||||
f"{path.name} 的编译命令漏传帧策略",
|
||||
)
|
||||
self.assertIn(
|
||||
"--timeline-output",
|
||||
block,
|
||||
f"{path.name} 的编译命令漏传时间轴输出",
|
||||
)
|
||||
if "video_job.py" in block:
|
||||
self.assertIn(
|
||||
"--stage",
|
||||
block,
|
||||
f"{path.name} 的生成命令漏传 Pilot/production 阶段",
|
||||
)
|
||||
|
||||
# ---------- evals 覆盖关键场景 ----------
|
||||
|
||||
def test_evals_cover_single_anime_baked_case(self) -> None:
|
||||
evals = json.loads(read("evals/evals.json"))["evals"]
|
||||
prompts = [item["prompt"] for item in evals]
|
||||
expected = [item["expected_output"] for item in evals]
|
||||
self.assertTrue(
|
||||
any("单人" in p and "动漫" in p for p in prompts),
|
||||
"evals 必须包含单人动漫场景用例",
|
||||
)
|
||||
self.assertTrue(
|
||||
any("Concept Contract" in e and "baked-video" in e for e in expected),
|
||||
"evals 必须断言 Concept Contract 与 baked-video 路线",
|
||||
)
|
||||
self.assertTrue(
|
||||
any("拒绝" in e and "阈值" in e for e in expected),
|
||||
"evals 必须断言禁止用调阈值掩盖抠色缺陷",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "image_job.py"
|
||||
sys.path.insert(0, str(SCRIPT.parent))
|
||||
import image_job
|
||||
|
||||
FAKE_KEY = "FAKE-ZENMUX-" + "0" * 16
|
||||
|
||||
|
||||
def png_bytes(mode: str, corner_alpha: int = 0) -> bytes:
|
||||
if mode == "RGBA":
|
||||
image = Image.new("RGBA", (64, 64), (0, 0, 0, corner_alpha))
|
||||
for x in range(16, 48):
|
||||
for y in range(16, 48):
|
||||
image.putpixel((x, y), (200, 80, 40, 255))
|
||||
else:
|
||||
image = Image.new("RGB", (64, 64), (120, 160, 200))
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self.body = json.dumps(payload).encode("utf-8")
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self.body
|
||||
|
||||
def __enter__(self) -> "FakeResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class ImageJobTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def generate(self, image: bytes, background: str, references: list[Path] | None = None):
|
||||
requests = []
|
||||
|
||||
def fake_urlopen(request, timeout=0):
|
||||
requests.append(request)
|
||||
return FakeResponse({"data": [{"b64_json": base64.b64encode(image).decode("ascii")}]})
|
||||
|
||||
output = self.root / "K0.png"
|
||||
with mock.patch.object(image_job, "require_api_key", return_value=FAKE_KEY), \
|
||||
mock.patch.object(image_job.urllib.request, "urlopen", side_effect=fake_urlopen), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
result = image_job.generate_image("测试", output, background=background, references=references)
|
||||
return result, requests
|
||||
|
||||
def test_background_is_required_on_cli(self):
|
||||
run = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--prompt", "x", "--output", str(self.root / "a.png"), "--dry-run"],
|
||||
capture_output=True, text=True, env={**os.environ, "ZENMUX_API_KEY": ""},
|
||||
)
|
||||
self.assertNotEqual(run.returncode, 0)
|
||||
self.assertIn("--background", run.stderr)
|
||||
|
||||
def test_dry_run_needs_no_key_and_routes_references_to_edits(self):
|
||||
reference = self.root / "ref.png"
|
||||
reference.write_bytes(png_bytes("RGB"))
|
||||
base = [sys.executable, str(SCRIPT), "--prompt", "x", "--output", str(self.root / "a.png"), "--background", "opaque", "--dry-run"]
|
||||
env = {key: value for key, value in os.environ.items() if key != "ZENMUX_API_KEY"}
|
||||
generations = json.loads(subprocess.run(base, capture_output=True, text=True, check=True, env=env).stdout)
|
||||
edits = json.loads(subprocess.run([*base, "--image", str(reference)], capture_output=True, text=True, check=True, env=env).stdout)
|
||||
self.assertTrue(generations["endpoint"].endswith("/images/generations"))
|
||||
self.assertTrue(edits["endpoint"].endswith("/images/edits"))
|
||||
self.assertEqual(edits["background"], "opaque")
|
||||
self.assertEqual(edits["references"], [str(reference.resolve())])
|
||||
|
||||
def test_size_follows_model_constraints(self):
|
||||
self.assertEqual(image_job.parse_size("2048x1152", image_job.DEFAULT_IMAGE_MODEL), (2048, 1152))
|
||||
for bad in ("1000x1000", "4096x1024", "512x512", "wide"):
|
||||
with self.assertRaises(ValueError):
|
||||
image_job.parse_size(bad, image_job.DEFAULT_IMAGE_MODEL)
|
||||
|
||||
def test_transparent_keyframe_with_real_alpha_is_accepted(self):
|
||||
result, requests = self.generate(png_bytes("RGBA", corner_alpha=0), "transparent")
|
||||
self.assertEqual(result.name, "K0.png")
|
||||
payload = json.loads(requests[0].data)
|
||||
self.assertEqual(payload["background"], "transparent")
|
||||
self.assertNotIn("transparent background", payload["prompt"])
|
||||
with Image.open(result) as saved:
|
||||
self.assertIn("A", saved.getbands())
|
||||
|
||||
def test_transparent_request_without_real_alpha_is_rejected(self):
|
||||
for image in (png_bytes("RGB"), png_bytes("RGBA", corner_alpha=255)):
|
||||
for leftover in self.root.glob("K0*"):
|
||||
leftover.unlink()
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
self.generate(image, "transparent")
|
||||
self.assertIn("透明验收失败", str(raised.exception))
|
||||
self.assertFalse((self.root / "K0.png").exists())
|
||||
self.assertTrue((self.root / "K0.rejected.png").exists())
|
||||
|
||||
def test_opaque_scene_keyframe_skips_alpha_gate(self):
|
||||
result, requests = self.generate(png_bytes("RGB"), "opaque")
|
||||
self.assertEqual(result.name, "K0.png")
|
||||
self.assertEqual(json.loads(requests[0].data)["background"], "opaque")
|
||||
|
||||
def test_references_use_multipart_edits(self):
|
||||
reference = self.root / "product.png"
|
||||
reference.write_bytes(png_bytes("RGB"))
|
||||
_, requests = self.generate(png_bytes("RGB"), "opaque", references=[reference])
|
||||
request = requests[0]
|
||||
self.assertTrue(request.full_url.endswith("/images/edits"))
|
||||
self.assertIn("multipart/form-data", request.get_header("Content-type"))
|
||||
self.assertIn(b'name="image[]"; filename="product.png"', request.data)
|
||||
|
||||
def test_refuses_to_overwrite_existing_output(self):
|
||||
(self.root / "K0.png").write_bytes(b"keep")
|
||||
with mock.patch.object(image_job, "require_api_key", side_effect=AssertionError("不应读取密钥")):
|
||||
with self.assertRaises(FileExistsError):
|
||||
image_job.generate_image("测试", self.root / "K0.png", background="opaque")
|
||||
self.assertEqual((self.root / "K0.png").read_bytes(), b"keep")
|
||||
|
||||
def test_http_error_does_not_echo_key(self):
|
||||
fake_key = FAKE_KEY
|
||||
error = urllib.error.HTTPError("https://zenmux.ai", 401, "denied", {}, io.BytesIO(f"bad key {fake_key}".encode()))
|
||||
self.addCleanup(error.close)
|
||||
with mock.patch.object(image_job, "require_api_key", return_value=fake_key), \
|
||||
mock.patch.object(image_job.urllib.request, "urlopen", side_effect=error), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
image_job.generate_image("测试", self.root / "K0.png", background="opaque")
|
||||
self.assertNotIn(fake_key, str(raised.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,232 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "motion_budget.py"
|
||||
SPEC = importlib.util.spec_from_file_location("motion_budget", MODULE_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MOTION_BUDGET = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = MOTION_BUDGET
|
||||
SPEC.loader.exec_module(MOTION_BUDGET)
|
||||
|
||||
|
||||
def arguments(**overrides: object) -> Namespace:
|
||||
values: dict[str, object] = {
|
||||
"frames": 96,
|
||||
"display": (240, 240),
|
||||
"dpr": 1.0,
|
||||
"max_texture": 4096,
|
||||
"driver": "pointer",
|
||||
"time_control": "scrub",
|
||||
"parameter_space": "circular",
|
||||
"access": "auto",
|
||||
"background_owner": "page",
|
||||
"atlas_max_memory_mib": 192.0,
|
||||
"linear_video_min_frames": 180,
|
||||
"source": None,
|
||||
"cell": None,
|
||||
"scroll_pages": None,
|
||||
"frames_per_page": 24.0,
|
||||
}
|
||||
values.update(overrides)
|
||||
return Namespace(**values)
|
||||
|
||||
|
||||
class MotionBudgetSelectionTests(unittest.TestCase):
|
||||
def test_cli_requires_explicit_background_owner(self) -> None:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(MODULE_PATH),
|
||||
"--frames",
|
||||
"240",
|
||||
"--display",
|
||||
"1280x720",
|
||||
"--strict",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
self.assertNotEqual(completed.returncode, 0)
|
||||
self.assertIn("--background-owner", completed.stderr)
|
||||
|
||||
def test_small_random_sequence_selects_alpha_atlas(self) -> None:
|
||||
report = MOTION_BUDGET.build_report(arguments())
|
||||
|
||||
self.assertEqual(report["delivery"]["selected"], "alpha-atlas")
|
||||
self.assertEqual(report["runtime"]["renderer"], "css-alpha-atlas")
|
||||
self.assertEqual(report["runtime"]["controller"], "frame-scrub")
|
||||
self.assertTrue(report["passes"])
|
||||
|
||||
def test_large_circular_sequence_falls_back_to_chroma_video(self) -> None:
|
||||
report = MOTION_BUDGET.build_report(
|
||||
arguments(
|
||||
frames=48,
|
||||
display=(640, 640),
|
||||
parameter_space="circular",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(report["texture"]["columnsPerSheet"], 6)
|
||||
self.assertEqual(report["texture"]["rowsPerSheet"], 6)
|
||||
self.assertEqual(report["texture"]["framesPerSheet"], 36)
|
||||
self.assertEqual(report["texture"]["sheetCount"], 2)
|
||||
self.assertEqual(report["delivery"]["selected"], "chroma-video")
|
||||
self.assertIn(
|
||||
"atlas-budget-exceeded",
|
||||
report["delivery"]["reasonCodes"],
|
||||
)
|
||||
self.assertTrue(report["passes"])
|
||||
|
||||
def test_large_linear_scroll_selects_chroma_video(self) -> None:
|
||||
report = MOTION_BUDGET.build_report(
|
||||
arguments(
|
||||
frames=551,
|
||||
display=(1536, 864),
|
||||
driver="scroll",
|
||||
parameter_space="linear",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(report["delivery"]["selected"], "chroma-video")
|
||||
self.assertEqual(report["runtime"]["renderer"], "webgl-chroma-video")
|
||||
self.assertEqual(report["runtime"]["controller"], "frame-scrub")
|
||||
self.assertIn("long-linear-sequence", report["delivery"]["reasonCodes"])
|
||||
self.assertTrue(report["passes"])
|
||||
|
||||
def test_two_dimensional_input_is_never_flattened_to_video(self) -> None:
|
||||
report = MOTION_BUDGET.build_report(
|
||||
arguments(
|
||||
frames=400,
|
||||
display=(900, 900),
|
||||
parameter_space="2d",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(report["delivery"]["selected"], "alpha-atlas")
|
||||
self.assertIn(
|
||||
"two-dimensional-parameter-needs-discrete-frames",
|
||||
report["delivery"]["reasonCodes"],
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"拆分参数轴" in failure and "用户确认" in failure
|
||||
for failure in report["failures"]
|
||||
)
|
||||
)
|
||||
self.assertFalse(report["passes"])
|
||||
|
||||
def test_discrete_states_are_split_instead_of_flattened(self) -> None:
|
||||
report = MOTION_BUDGET.build_report(
|
||||
arguments(
|
||||
frames=400,
|
||||
display=(900, 900),
|
||||
driver="state",
|
||||
parameter_space="discrete",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(report["delivery"]["selected"], "alpha-atlas")
|
||||
self.assertIn(
|
||||
"discrete-states-need-independent-assets",
|
||||
report["delivery"]["reasonCodes"],
|
||||
)
|
||||
self.assertFalse(report["passes"])
|
||||
|
||||
def test_baked_scene_video_when_background_belongs_to_video(self) -> None:
|
||||
report = MOTION_BUDGET.build_report(
|
||||
arguments(
|
||||
frames=551,
|
||||
display=(1536, 864),
|
||||
driver="scroll",
|
||||
parameter_space="linear",
|
||||
background_owner="video",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(report["delivery"]["selected"], "baked-video")
|
||||
self.assertEqual(report["runtime"]["renderer"], "baked-video")
|
||||
self.assertEqual(report["runtime"]["controller"], "frame-scrub")
|
||||
self.assertIn(
|
||||
"background-baked-into-video",
|
||||
report["delivery"]["reasonCodes"],
|
||||
)
|
||||
self.assertIn("long-linear-sequence", report["delivery"]["reasonCodes"])
|
||||
self.assertEqual(report["backgroundOwner"], "video")
|
||||
self.assertTrue(report["passes"])
|
||||
|
||||
def test_scroll_segment_play_is_not_misclassified_as_scrub(self) -> None:
|
||||
report = MOTION_BUDGET.build_report(
|
||||
arguments(
|
||||
frames=240,
|
||||
display=(1280, 720),
|
||||
driver="scroll",
|
||||
time_control="segment-play",
|
||||
parameter_space="linear",
|
||||
background_owner="video",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(report["delivery"]["selected"], "baked-video")
|
||||
self.assertEqual(report["runtime"]["controller"], "segment-playback")
|
||||
self.assertEqual(report["access"], "sequential")
|
||||
|
||||
def test_cli_requires_explicit_time_control(self) -> None:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(MODULE_PATH),
|
||||
"--frames",
|
||||
"240",
|
||||
"--display",
|
||||
"1280x720",
|
||||
"--background-owner",
|
||||
"video",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
self.assertNotEqual(completed.returncode, 0)
|
||||
self.assertIn("--time-control", completed.stderr)
|
||||
|
||||
def test_baked_video_is_not_failed_by_atlas_budget(self) -> None:
|
||||
report = MOTION_BUDGET.build_report(
|
||||
arguments(
|
||||
frames=900,
|
||||
display=(900, 900),
|
||||
parameter_space="circular",
|
||||
background_owner="video",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(report["delivery"]["selected"], "baked-video")
|
||||
self.assertTrue(report["passes"])
|
||||
|
||||
def test_baked_video_rejects_flattened_two_dimensional_input(self) -> None:
|
||||
report = MOTION_BUDGET.build_report(
|
||||
arguments(
|
||||
frames=400,
|
||||
display=(900, 900),
|
||||
parameter_space="2d",
|
||||
background_owner="video",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(report["delivery"]["selected"], "baked-video")
|
||||
self.assertIn(
|
||||
"baked-video-needs-independent-clips",
|
||||
report["delivery"]["reasonCodes"],
|
||||
)
|
||||
self.assertFalse(report["passes"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
|
||||
def load(name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, SCRIPT_DIR / f"{name}.py")
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
GATE = load("production_gate")
|
||||
VIDEO = load("video_job")
|
||||
FFMPEG = shutil.which("ffmpeg")
|
||||
|
||||
|
||||
def write(path: Path, value: bytes) -> Path:
|
||||
path.write_bytes(value)
|
||||
return path
|
||||
|
||||
|
||||
def make_color_video(path: Path, color: str) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
FFMPEG or "ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color={color}:size=64x64:rate=12:duration=1",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
class ProductionGateTests(unittest.TestCase):
|
||||
def approval(self, root: Path, continuity: str = "chain") -> Path:
|
||||
files = {
|
||||
"contract": write(
|
||||
root / "contract.yaml",
|
||||
f"subject_count: 1\nclip_continuity: {continuity}\n".encode(),
|
||||
),
|
||||
"first_frame": write(root / "first.png", b"first"),
|
||||
"last_frame": write(root / "last.png", b"last"),
|
||||
"video": write(root / "pilot.mp4", b"video"),
|
||||
"page_evidence": write(root / "page.png", b"page"),
|
||||
}
|
||||
output = root / "approval.json"
|
||||
GATE.create_pilot_approval(
|
||||
Namespace(
|
||||
**files,
|
||||
identity_bible=None,
|
||||
reviewer="visual-review",
|
||||
notes="passed",
|
||||
decision="pass",
|
||||
output=output,
|
||||
force=False,
|
||||
)
|
||||
)
|
||||
return output
|
||||
|
||||
def test_pilot_approval_detects_changed_artifact(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
approval = self.approval(root)
|
||||
|
||||
GATE.validate_pilot_approval(approval)
|
||||
# Updating pageEvidence does not invalidate pilot approval as long as file exists
|
||||
(root / "page.png").write_bytes(b"updated screenshot")
|
||||
self.assertTrue(GATE.validate_pilot_approval(approval)["passed"])
|
||||
|
||||
# Changing core generation artifact invalidates pilot approval
|
||||
(root / "first.png").write_bytes(b"changed")
|
||||
with self.assertRaisesRegex(ValueError, "批准失效"):
|
||||
GATE.validate_pilot_approval(approval)
|
||||
|
||||
def test_contract_continuity_requires_unique_top_level_value(self) -> None:
|
||||
invalid_contracts = {
|
||||
"nested": "metadata:\n clip_continuity: independent\n",
|
||||
"duplicate": (
|
||||
"clip_continuity: chain\n"
|
||||
"clip_continuity: independent\n"
|
||||
),
|
||||
"string": 'notes: "clip_continuity: independent"\n',
|
||||
"comment": "# clip_continuity: independent\nsubject_count: 1\n",
|
||||
"missing": "subject_count: 1\n",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for name, content in invalid_contracts.items():
|
||||
with self.subTest(name=name):
|
||||
path = root / f"{name}.yaml"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
GATE.contract_continuity_mode(path)
|
||||
|
||||
valid = root / "valid.yaml"
|
||||
valid.write_text(
|
||||
"subject_count: 1\nclip_continuity: chain # required\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(GATE.contract_continuity_mode(valid), "chain")
|
||||
|
||||
def test_frame_chain_records_exact_hash_match(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
previous = write(root / "tail.png", b"same-frame")
|
||||
current = write(root / "first.png", b"same-frame")
|
||||
manifest = root / "frame-chain.json"
|
||||
|
||||
link = GATE.verify_frame_chain(previous, current, 2, manifest)
|
||||
|
||||
self.assertTrue(link["exactSha256Match"])
|
||||
payload = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
self.assertEqual(payload["links"][0]["segmentIndex"], 2)
|
||||
|
||||
def test_frame_chain_rejects_modified_next_first(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
previous = write(root / "tail.png", b"tail")
|
||||
current = write(root / "first.png", b"not-tail")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "连续帧链断裂"):
|
||||
GATE.verify_frame_chain(previous, current, 2)
|
||||
|
||||
def test_frame_similarity_rejects_visible_change(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
first = root / "first.png"
|
||||
second = root / "second.png"
|
||||
Image.new("RGB", (32, 32), (255, 0, 0)).save(first)
|
||||
Image.new("RGB", (32, 32), (0, 0, 255)).save(second)
|
||||
|
||||
metrics = GATE.frame_similarity(first, second)
|
||||
|
||||
self.assertLess(metrics["ssim"], 0.97)
|
||||
self.assertGreater(metrics["normalizedMae"], 0.04)
|
||||
|
||||
@unittest.skipUnless(FFMPEG, "需要 ffmpeg 才能验证成片接缝")
|
||||
def test_output_chain_checks_decoded_video_edges(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
previous = root / "previous.mp4"
|
||||
current = root / "current.mp4"
|
||||
make_color_video(previous, "red")
|
||||
make_color_video(current, "red")
|
||||
|
||||
result = GATE.verify_output_chain(
|
||||
previous,
|
||||
current,
|
||||
2,
|
||||
root / "frame-chain.json",
|
||||
root / "evidence",
|
||||
)
|
||||
|
||||
self.assertTrue(result["passed"])
|
||||
self.assertIn("ssim", result["metrics"])
|
||||
|
||||
def test_video_production_requires_pilot_approval(self) -> None:
|
||||
args = Namespace(
|
||||
stage="production",
|
||||
segment_index=2,
|
||||
pilot_approval=None,
|
||||
continuity_mode="independent",
|
||||
previous_tail=None,
|
||||
first_frame=None,
|
||||
frame_chain_manifest=None,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "pilot-approval"):
|
||||
VIDEO.validate_production_gate(args)
|
||||
|
||||
def test_video_chain_requires_exact_previous_tail(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
approval = self.approval(root)
|
||||
previous = write(root / "tail.png", b"tail")
|
||||
current = write(root / "next.png", b"different")
|
||||
args = Namespace(
|
||||
stage="production",
|
||||
segment_index=2,
|
||||
pilot_approval=approval,
|
||||
continuity_mode="chain",
|
||||
previous_tail=previous,
|
||||
first_frame=current,
|
||||
frame_chain_manifest=root / "frame-chain.json",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "连续帧链断裂"):
|
||||
VIDEO.validate_production_gate(args)
|
||||
|
||||
def test_continuous_contract_rejects_independent_bypass(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
approval = self.approval(root, "chain")
|
||||
args = Namespace(
|
||||
stage="production",
|
||||
segment_index=2,
|
||||
pilot_approval=approval,
|
||||
continuity_mode="independent",
|
||||
previous_tail=None,
|
||||
first_frame=None,
|
||||
frame_chain_manifest=None,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "合同要求 chain"):
|
||||
VIDEO.validate_production_gate(args)
|
||||
|
||||
def test_independent_contract_allows_independent_production(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
approval = self.approval(root, "independent")
|
||||
args = Namespace(
|
||||
stage="production",
|
||||
segment_index=2,
|
||||
pilot_approval=approval,
|
||||
continuity_mode="independent",
|
||||
previous_tail=None,
|
||||
first_frame=None,
|
||||
frame_chain_manifest=None,
|
||||
)
|
||||
|
||||
result = VIDEO.validate_production_gate(args)
|
||||
|
||||
self.assertEqual(result["continuityMode"], "independent")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,29 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'scripts'))
|
||||
import oil_motion_config as config
|
||||
|
||||
|
||||
class BusinessCredentialTests(unittest.TestCase):
|
||||
def test_profile_environment_reaches_business_reader(self):
|
||||
with patch.dict(os.environ, {'ZENMUX_API_KEY': 'TEST_KEY'}, clear=True), patch.object(Path, 'read_text', side_effect=AssertionError('不应读取旧凭据文件')):
|
||||
self.assertEqual(config.require_api_key(), 'TEST_KEY')
|
||||
|
||||
def test_missing_key_points_to_page_and_run_entry(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {'OIL_MOTION_CONFIG_FILE': str(Path(tmp) / 'config.json')}, clear=True):
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
config.require_api_key()
|
||||
message = str(raised.exception)
|
||||
self.assertIn('profile.ts', message)
|
||||
self.assertIn('run default', message)
|
||||
self.assertNotIn('oil_motion_config.py set', message)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""系统后端替身测试;不读取或写入真实凭据。"""
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
SCRIPTS = ROOT / 'scripts'
|
||||
if not SCRIPTS.exists(): SCRIPTS = ROOT / 'see/scripts'
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import secure_credentials as store
|
||||
|
||||
class CredentialTests(unittest.TestCase):
|
||||
def test_save_read_delete_only_use_backend(self):
|
||||
values = {}
|
||||
backend = MagicMock()
|
||||
backend.get_password.side_effect = lambda service, ref: values.get(ref)
|
||||
backend.set_password.side_effect = lambda service, ref, secret: values.update({ref:secret})
|
||||
backend.delete_password.side_effect = lambda service, ref: values.pop(ref)
|
||||
output = io.StringIO()
|
||||
with patch.object(store, 'backend', return_value=backend), redirect_stdout(output), redirect_stderr(output):
|
||||
store.save('test/reference', 'fake-credential-for-tests')
|
||||
self.assertEqual(store.read('test/reference'), 'fake-credential-for-tests')
|
||||
store.delete('test/reference')
|
||||
self.assertEqual(values, {})
|
||||
self.assertNotIn('fake-credential-for-tests', output.getvalue())
|
||||
|
||||
def test_backend_failure_never_echoes_secret(self):
|
||||
backend = MagicMock()
|
||||
backend.set_password.side_effect = RuntimeError('fake-credential-for-tests')
|
||||
with patch.object(store, 'backend', return_value=backend):
|
||||
with self.assertRaises(RuntimeError) as error:
|
||||
store.save('test/reference', 'fake-credential-for-tests')
|
||||
self.assertNotIn('fake-credential-for-tests', str(error.exception))
|
||||
|
||||
def test_unavailable_system_backend_does_not_select_plaintext(self):
|
||||
with patch.object(store.importlib, 'import_module', side_effect=ImportError), self.assertRaises(RuntimeError):
|
||||
store.backend()
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,345 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT_DIR = ROOT / "scripts"
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"video_job",
|
||||
SCRIPT_DIR / "video_job.py",
|
||||
)
|
||||
assert SPEC and SPEC.loader
|
||||
VIDEO = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = VIDEO
|
||||
SPEC.loader.exec_module(VIDEO)
|
||||
|
||||
FFMPEG = shutil.which("ffmpeg")
|
||||
|
||||
# 拆开拼接,避免本测试文件自己被冲突标记扫描命中
|
||||
CONFLICT_MARKER = re.compile(
|
||||
r"^(?:{lt}|{gt}|{pipe}|{eq})$".format(
|
||||
lt="<" * 7 + r".*",
|
||||
gt=">" * 7 + r".*",
|
||||
pipe=r"\|" * 7 + r".*",
|
||||
eq="=" * 7,
|
||||
)
|
||||
)
|
||||
SKIPPED_DIRS = {".git", "__pycache__", ".venv", "node_modules"}
|
||||
|
||||
|
||||
class VideoResolutionTests(unittest.TestCase):
|
||||
def test_h3_max_accepts_supported_resolutions_and_normalizes_case(self) -> None:
|
||||
args = VIDEO.parser().parse_args(["--prompt", "测试", "--output", "out.mp4"])
|
||||
self.assertEqual(VIDEO.build_payload(args)["resolution"], "768P")
|
||||
for resolution in ("480P", "480p", "768P", "768p"):
|
||||
with self.subTest(resolution=resolution):
|
||||
args.resolution = resolution
|
||||
self.assertEqual(
|
||||
VIDEO.build_payload(args)["resolution"], resolution.upper()
|
||||
)
|
||||
|
||||
def test_h3_max_rejects_unsupported_resolutions_before_credentials_or_submit(self) -> None:
|
||||
for resolution in ("2K", "1080P", "4K", "720p"):
|
||||
with self.subTest(resolution=resolution):
|
||||
args = VIDEO.parser().parse_args(
|
||||
["--prompt", "测试", "--resolution", resolution, "--output", "out.mp4"]
|
||||
)
|
||||
with (
|
||||
mock.patch.object(VIDEO, "require_api_key") as credentials,
|
||||
mock.patch.object(VIDEO, "request_json") as request,
|
||||
self.assertRaises(ValueError) as raised,
|
||||
):
|
||||
VIDEO.generate(args)
|
||||
self.assertIn(VIDEO.DEFAULT_MODEL, str(raised.exception))
|
||||
self.assertIn(resolution, str(raised.exception))
|
||||
self.assertIn("--resolution 768P", str(raised.exception))
|
||||
self.assertIn("--resolution 480P", str(raised.exception))
|
||||
credentials.assert_not_called()
|
||||
request.assert_not_called()
|
||||
|
||||
def test_other_models_keep_their_native_resolution(self) -> None:
|
||||
args = VIDEO.parser().parse_args(
|
||||
[
|
||||
"--prompt", "测试", "--output", "out.mp4",
|
||||
"--model", "minimax/minimax-h3", "--resolution", "2K",
|
||||
]
|
||||
)
|
||||
self.assertEqual(VIDEO.build_payload(args)["resolution"], "2K")
|
||||
|
||||
|
||||
class ConflictMarkerTests(unittest.TestCase):
|
||||
def test_no_git_conflict_markers_anywhere_in_skill(self) -> None:
|
||||
offenders: list[str] = []
|
||||
for path in sorted(ROOT.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if SKIPPED_DIRS & set(path.relative_to(ROOT).parts):
|
||||
continue
|
||||
data = path.read_bytes()
|
||||
if b"\x00" in data[:8192]:
|
||||
continue
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
for number, line in enumerate(text.splitlines(), start=1):
|
||||
if CONFLICT_MARKER.match(line):
|
||||
relative = path.relative_to(ROOT)
|
||||
offenders.append(f"{relative}:{number}")
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
"Skill 内不得残留 Git 冲突标记:" + ", ".join(offenders),
|
||||
)
|
||||
|
||||
|
||||
def make_test_video(path: Path, frames: int = 24) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
FFMPEG or "ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"testsrc=duration={frames / 12}:size=64x64:rate=12",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def decode_rgb(path: Path) -> list[tuple[int, int, int]]:
|
||||
with Image.open(path) as opened:
|
||||
image = opened.convert("RGB")
|
||||
if hasattr(image, "get_flattened_data"):
|
||||
return list(image.get_flattened_data())
|
||||
return list(image.getdata())
|
||||
|
||||
|
||||
@unittest.skipUnless(FFMPEG, "需要 ffmpeg 才能验证尾帧提取")
|
||||
class LastFrameFallbackTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.directory.name)
|
||||
self.video = self.root / "master.mp4"
|
||||
make_test_video(self.video)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.directory.cleanup()
|
||||
|
||||
def test_extract_last_frame_returns_real_tail(self) -> None:
|
||||
output = self.root / "tail.jpg"
|
||||
|
||||
VIDEO.extract_last_frame(self.video, output)
|
||||
|
||||
self.assertTrue(output.is_file())
|
||||
self.assertGreater(output.stat().st_size, 0)
|
||||
tail = decode_rgb(output)
|
||||
reference = self.root / "reference.png"
|
||||
subprocess.run(
|
||||
[
|
||||
FFMPEG or "ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-sseof",
|
||||
"-0.2",
|
||||
"-i",
|
||||
str(self.video),
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(reference),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
first = self.root / "first.png"
|
||||
subprocess.run(
|
||||
[
|
||||
FFMPEG or "ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
str(self.video),
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(first),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def mean_delta(a: list[tuple[int, int, int]], b: list[tuple[int, int, int]]) -> float:
|
||||
return sum(
|
||||
abs(pixel_a[channel] - pixel_b[channel])
|
||||
for pixel_a, pixel_b in zip(a, b, strict=True)
|
||||
for channel in range(3)
|
||||
) / (len(a) * 3)
|
||||
|
||||
# 提取结果接近真实尾帧,而不是被静默替换成首帧
|
||||
self.assertLess(mean_delta(tail, decode_rgb(reference)), 12)
|
||||
self.assertGreater(mean_delta(tail, decode_rgb(first)), 1)
|
||||
|
||||
def run_generate(self, final_response: dict) -> tuple[Path, Path, dict]:
|
||||
output = self.root / "out.mp4"
|
||||
last_frame_output = self.root / "tail-out.jpg"
|
||||
metadata = self.root / "meta.json"
|
||||
args = Namespace(
|
||||
prompt="测试",
|
||||
prompt_file=None,
|
||||
first_frame=None,
|
||||
last_frame=None,
|
||||
loop_frame=False,
|
||||
reference_image=[],
|
||||
model="minimax/minimax-h3",
|
||||
resolution="768p",
|
||||
ratio=None,
|
||||
duration=5,
|
||||
seed=None,
|
||||
frames=None,
|
||||
stage="pilot",
|
||||
segment_index=1,
|
||||
pilot_approval=None,
|
||||
continuity_mode=None,
|
||||
previous_tail=None,
|
||||
frame_chain_manifest=None,
|
||||
output=str(output),
|
||||
last_frame_output=str(last_frame_output),
|
||||
metadata=str(metadata),
|
||||
poll_interval=0.01,
|
||||
timeout=30.0,
|
||||
force=False,
|
||||
)
|
||||
|
||||
def fake_request(method: str, url: str, api_key: str, payload=None) -> dict:
|
||||
if method == "POST":
|
||||
return {"id": "job-1"}
|
||||
return final_response
|
||||
|
||||
def fake_download(url: str, target: Path) -> None:
|
||||
if "last-frame" in url:
|
||||
target.write_bytes((self.root / "api-tail.png").read_bytes())
|
||||
else:
|
||||
shutil.copy2(self.video, target)
|
||||
|
||||
api_tail = Image.new("RGB", (64, 64), (12, 34, 56))
|
||||
api_tail.save(self.root / "api-tail.png")
|
||||
|
||||
with (
|
||||
mock.patch.object(VIDEO, "request_json", side_effect=fake_request),
|
||||
mock.patch.object(VIDEO, "download", side_effect=fake_download),
|
||||
mock.patch.object(VIDEO, "require_api_key", return_value="key"),
|
||||
):
|
||||
VIDEO.generate(args)
|
||||
return output, last_frame_output, json.loads(
|
||||
metadata.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
def test_api_last_frame_is_preferred(self) -> None:
|
||||
_, last_frame_output, metadata = self.run_generate(
|
||||
{
|
||||
"status": "succeeded",
|
||||
"video_url": "https://example.com/video.mp4",
|
||||
"last_frame_url": "https://example.com/last-frame.jpg",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(last_frame_output.is_file())
|
||||
self.assertEqual(decode_rgb(last_frame_output)[0], (12, 34, 56))
|
||||
self.assertEqual(metadata["lastFrame"]["source"], "api")
|
||||
self.assertEqual(
|
||||
metadata["lastFrame"]["path"], str(last_frame_output.resolve())
|
||||
)
|
||||
|
||||
def test_missing_api_last_frame_falls_back_to_video(self) -> None:
|
||||
_, last_frame_output, metadata = self.run_generate(
|
||||
{
|
||||
"status": "succeeded",
|
||||
"video_url": "https://example.com/video.mp4",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(last_frame_output.is_file())
|
||||
with Image.open(last_frame_output) as image:
|
||||
self.assertEqual(image.size, (64, 64))
|
||||
self.assertEqual(metadata["lastFrame"]["source"], "video-fallback")
|
||||
|
||||
def test_default_last_frame_output_is_always_produced(self) -> None:
|
||||
output = self.root / "default.mp4"
|
||||
metadata = self.root / "default.meta.json"
|
||||
args = Namespace(
|
||||
prompt="测试",
|
||||
prompt_file=None,
|
||||
first_frame=None,
|
||||
last_frame=None,
|
||||
loop_frame=False,
|
||||
reference_image=[],
|
||||
model="minimax/minimax-h3",
|
||||
resolution="768p",
|
||||
ratio=None,
|
||||
duration=5,
|
||||
seed=None,
|
||||
frames=None,
|
||||
stage="pilot",
|
||||
segment_index=1,
|
||||
pilot_approval=None,
|
||||
continuity_mode=None,
|
||||
previous_tail=None,
|
||||
frame_chain_manifest=None,
|
||||
output=str(output),
|
||||
last_frame_output=None,
|
||||
metadata=str(metadata),
|
||||
poll_interval=0.01,
|
||||
timeout=30.0,
|
||||
force=False,
|
||||
)
|
||||
with (
|
||||
mock.patch.object(
|
||||
VIDEO,
|
||||
"request_json",
|
||||
side_effect=[
|
||||
{"id": "job-1"},
|
||||
{
|
||||
"status": "succeeded",
|
||||
"video_url": "https://example.com/video.mp4",
|
||||
},
|
||||
],
|
||||
),
|
||||
mock.patch.object(
|
||||
VIDEO,
|
||||
"download",
|
||||
side_effect=lambda url, target: shutil.copy2(self.video, target),
|
||||
),
|
||||
mock.patch.object(VIDEO, "require_api_key", return_value="key"),
|
||||
):
|
||||
VIDEO.generate(args)
|
||||
|
||||
default_tail = self.root / "default-last-frame.jpg"
|
||||
self.assertTrue(default_tail.is_file())
|
||||
payload = json.loads(metadata.read_text(encoding="utf-8"))
|
||||
self.assertEqual(payload["lastFrame"]["source"], "video-fallback")
|
||||
self.assertEqual(
|
||||
payload["lastFrame"]["path"], str(default_tail.resolve())
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user