350 lines
18 KiB
Python
350 lines
18 KiB
Python
"""저장소 계약의 회귀 검사. 실제 렌더링·승인·미디어 검수는 수행하지 않는다."""
|
|
from copy import deepcopy
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import tomllib
|
|
import unittest
|
|
|
|
from jsonschema import Draft202012Validator
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
PROFILE = dict(engine="BLENDER_EEVEE", device="GPU", fps=50, width=1080,
|
|
height=1920, samples=32, seed=1)
|
|
BLEND = dict(path="examples/blender-contract/preview.blend", verified=False,
|
|
sha256=None, blender_version=None)
|
|
IMAGE = dict(path="examples/blender-contract/preview.png", kind="image",
|
|
verified=False, duration_ms=None, width=None, height=None)
|
|
|
|
|
|
def read(path):
|
|
return json.loads((ROOT / path).read_text(encoding="utf-8"))
|
|
|
|
|
|
def artifact(stage, payload):
|
|
return dict(schema_version="1.1", project_id="contract-example", stage_id=stage,
|
|
revision=1, inputs=[], payload=payload)
|
|
|
|
|
|
def samples():
|
|
frames = [dict(id="c1-" + pos, cut_id="c1", position=pos, prompt="상태 설명",
|
|
reference_ids=["water"], scene_name="Water", frame_index=index)
|
|
for pos, index in [("start", 0), ("end", 200)]]
|
|
return {
|
|
"08-scenes": artifact("08-scenes", dict(scenes=[], connections=[], preview=None)),
|
|
"10-references": artifact("10-references", dict(request=dict(engine="Blender",
|
|
style_reference="references/1.png", render_profile=deepcopy(PROFILE), items=[
|
|
dict(id="water", kind="object", entity_id=None, prompt="물방울")]), result=None)),
|
|
"11-images": artifact("11-images", dict(request=dict(engine="Blender",
|
|
scene_path=BLEND["path"], render_profile=deepcopy(PROFILE), frames=frames), result=None)),
|
|
"12-motion": artifact("12-motion", dict(request=dict(engine="Blender",
|
|
scene_path=BLEND["path"], render_profile=deepcopy(PROFILE), clips=[dict(cut_id="c1",
|
|
start_frame_id="c1-start", end_frame_id="c1-end", duration_ms=4000,
|
|
motion_prompt="둥글게", scene_name="Water", start_frame=0, end_frame=200)]), result=None)),
|
|
}
|
|
|
|
|
|
def semantic_errors(data, context, complete=False):
|
|
"""1.1 구조 검사 후 사용. context={stage_id: artifact}; 파일·승인은 별도 확인."""
|
|
if data["schema_version"] != "1.1":
|
|
return []
|
|
errors = []
|
|
stage = data["stage_id"]
|
|
payload = data["payload"]
|
|
|
|
def require(condition, message):
|
|
if not condition:
|
|
errors.append(message)
|
|
|
|
def indexed(items, key):
|
|
ids = [item[key] for item in items]
|
|
require(all(ids) and len(ids) == len(set(ids)), "빈 식별자 또는 중복: " + key)
|
|
return {item[key]: item for item in items}
|
|
|
|
def walk(value):
|
|
if isinstance(value, dict):
|
|
for key, child in value.items():
|
|
if key in ("path", "scene_path") and isinstance(child, str) and child:
|
|
parts = child.replace("\\", "/").split("/")
|
|
require(not child.startswith(("/", "\\")) and ":" not in child
|
|
and ".." not in parts, "루트 상대 경로 필요: " + child)
|
|
if "kind" in value:
|
|
require(not child.lower().endswith(".blend"), ".blend는 media가 아니다")
|
|
walk(child)
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
walk(child)
|
|
|
|
walk(payload)
|
|
request = payload.get("request", {})
|
|
preview = payload.get("preview")
|
|
profile = request.get("render_profile") or (preview or {}).get("render_profile")
|
|
if profile:
|
|
require(profile["width"] * 16 == profile["height"] * 9, "RenderProfile은 9:16 필요")
|
|
cuts = context.get("07-cuts", {}).get("payload", {}).get("cuts", [])
|
|
cut_ids = {cut["id"] for cut in cuts}
|
|
if stage == "08-scenes" and complete:
|
|
require(preview is not None, "대표 품질 검토 자료 필요")
|
|
if stage == "08-scenes" and preview:
|
|
indexed(preview["samples"], "id")
|
|
require(bool(preview["samples"]), "대표 샘플 필요")
|
|
for sample in preview["samples"]:
|
|
require(set(sample["cut_ids"]) <= cut_ids, "대표 샘플의 알 수 없는 컷")
|
|
require(sample["media"]["verified"], "대표 미디어 미검증")
|
|
width, height = sample["media"]["width"], sample["media"]["height"]
|
|
require(isinstance(width, int) and isinstance(height, int)
|
|
and width * 16 == height * 9, "대표 실제 미디어는 9:16 필요")
|
|
require(preview["scene_file"]["verified"], "대표 .blend 미검증")
|
|
if stage == "10-references":
|
|
indexed(request["items"], "id")
|
|
entities = context.get("09-entities", {}).get("payload", {})
|
|
for item in request["items"]:
|
|
if item["kind"] in ("character", "background"):
|
|
bucket = "characters" if item["kind"] == "character" else "backgrounds"
|
|
require(item["entity_id"] in {x["id"] for x in entities.get(bucket, [])},
|
|
"알 수 없는 entity_id")
|
|
if stage == "11-images":
|
|
frames = indexed(request["frames"], "id")
|
|
refs = context.get("10-references", {}).get("payload", {}).get("request", {}).get("items", [])
|
|
reference_ids = {item["id"] for item in refs}
|
|
pairs = {}
|
|
for frame in frames.values():
|
|
require(frame["cut_id"] in cut_ids, "프레임의 알 수 없는 컷")
|
|
require(set(frame["reference_ids"]) <= reference_ids, "알 수 없는 reference_id")
|
|
pairs.setdefault(frame["cut_id"], []).append(frame)
|
|
require(set(pairs) == cut_ids, "컷별 start/end 누락")
|
|
for pair in pairs.values():
|
|
require(len(pair) == 2 and {x["position"] for x in pair} == {"start", "end"}, "start/end 중복 또는 누락")
|
|
require(len({x["scene_name"] for x in pair}) == 1, "같은 컷은 같은 Scene 필요")
|
|
if stage == "12-motion":
|
|
clips = indexed(request["clips"], "cut_id")
|
|
require(set(clips) == cut_ids, "클립 컷 누락 또는 추가")
|
|
previous = context.get("11-images", {}).get("payload", {})
|
|
result = previous.get("result")
|
|
require(bool(result) and result["scene_file"]["verified"], "11 실제 .blend 필요")
|
|
if result:
|
|
require(request["scene_path"] == result["scene_file"]["path"], "11 원본 경로 불일치")
|
|
require(request["render_profile"] == previous.get("request", {}).get("render_profile"), "11 RenderProfile 불일치")
|
|
frames = {x["id"]: x for x in previous.get("request", {}).get("frames", [])}
|
|
for clip in clips.values():
|
|
require(clip["end_frame"] > clip["start_frame"], "프레임 범위는 양수 필요")
|
|
for position in ("start", "end"):
|
|
frame = frames.get(clip[position + "_frame_id"])
|
|
require(bool(frame), "알 수 없는 frame_id")
|
|
if frame:
|
|
require(frame["cut_id"] == clip["cut_id"] and frame["position"] == position
|
|
and frame["scene_name"] == clip["scene_name"]
|
|
and frame["frame_index"] == clip[position + "_frame"], "프레임 상태 참조 불일치")
|
|
cut = next((x for x in cuts if x["id"] == clip["cut_id"]), None)
|
|
if cut:
|
|
require(clip["duration_ms"] == cut["end_ms"] - cut["start_ms"], "컷 실측 길이 불일치")
|
|
require(clip["duration_ms"] is not None and (clip["end_frame"] - clip["start_frame"]) * 1000
|
|
== clip["duration_ms"] * request["render_profile"]["fps"], "프레임 수와 컷 길이 불일치")
|
|
if complete and stage in ("10-references", "11-images", "12-motion"):
|
|
result = payload["result"]
|
|
require(result is not None, "result=null은 완료가 아니다")
|
|
if result:
|
|
key, requested_key, returned_key = {
|
|
"10-references": ("items", "id", "reference_id"),
|
|
"11-images": ("frames", "id", "frame_id"),
|
|
"12-motion": ("clips", "cut_id", "cut_id"),
|
|
}[stage]
|
|
returned = indexed(result[key], returned_key)
|
|
require(bool(request[key]) and set(returned) == {x[requested_key] for x in request[key]}, "요청/결과 누락 또는 추가")
|
|
require(all(x["media"]["verified"] for x in result[key]), "결과 미디어 미검증")
|
|
if stage != "12-motion":
|
|
require(result["scene_file"]["verified"], "결과 .blend 미검증")
|
|
return errors
|
|
|
|
|
|
class ContractTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.validators = {}
|
|
for name in ("project", "artifact"):
|
|
schema = read("contracts/" + name + ".schema.json")
|
|
Draft202012Validator.check_schema(schema)
|
|
cls.validators[name] = Draft202012Validator(schema)
|
|
|
|
def valid(self, data):
|
|
self.validators["artifact" if "stage_id" in data else "project"].validate(data)
|
|
|
|
def invalid(self, data):
|
|
self.assertFalse(self.validators["artifact" if "stage_id" in data else "project"].is_valid(data))
|
|
|
|
def test_legacy_templates_examples_and_production(self):
|
|
count = 0
|
|
for folder in ("templates", "examples", "projects"):
|
|
for path in (ROOT / folder).rglob("*.json"):
|
|
if "node_modules" in path.parts or ".cache" in path.parts:
|
|
continue
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
if isinstance(data, dict) and "schema_version" in data and "project_id" in data:
|
|
self.valid(data)
|
|
count += 1
|
|
self.assertGreaterEqual(count, 30)
|
|
|
|
def test_role_skill_and_document_links(self):
|
|
agents = list((ROOT / ".codex/agents").glob("*.toml"))
|
|
self.assertEqual(len(agents), 8)
|
|
for path in agents:
|
|
role = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
self.assertEqual(set(role), {"name", "description", "developer_instructions"})
|
|
self.assertEqual(role["name"], path.stem)
|
|
skills = list((ROOT / ".agents/skills").glob("*/SKILL.md"))
|
|
self.assertEqual(len(skills), 15)
|
|
documents = [ROOT / "AGENTS.md", ROOT / "CLAUDE.md",
|
|
*list((ROOT / "docs").glob("*.md")),
|
|
ROOT / "examples/blender-contract/README.md", *skills]
|
|
for path in documents:
|
|
text = path.read_text(encoding="utf-8")
|
|
for target in re.findall(r"\]\(([^)]+)\)", text):
|
|
if "://" not in target and not target.startswith("#"):
|
|
self.assertTrue((path.parent / target.split("#")[0]).resolve().exists(), (path, target))
|
|
|
|
def test_blender_contracts(self):
|
|
project = read("templates/project.json")
|
|
project["schema_version"] = "1.1"
|
|
project["settings"].pop("image_model", None)
|
|
project["settings"]["visual_engine"] = "Blender"
|
|
self.valid(project)
|
|
for data in samples().values():
|
|
self.valid(data)
|
|
|
|
def test_wrong_version_engine_and_extra_fields(self):
|
|
for data in samples().values():
|
|
for version in ("1.0", "2.0"):
|
|
bad = deepcopy(data)
|
|
bad["schema_version"] = version
|
|
self.invalid(bad)
|
|
if "request" in data["payload"]:
|
|
bad = deepcopy(data)
|
|
bad["payload"]["request"]["engine"] = "gpt-image-2.5-sunburst"
|
|
self.invalid(bad)
|
|
bad["payload"]["request"]["engine"] = "Blender"
|
|
bad["payload"]["request"]["model"] = "Blender"
|
|
self.invalid(bad)
|
|
|
|
def test_verified_blend_requires_hash_and_version(self):
|
|
data = samples()["08-scenes"]
|
|
data["payload"]["preview"] = dict(scene_file=deepcopy(BLEND),
|
|
render_profile=deepcopy(PROFILE), samples=[dict(id="p1", cut_ids=["c1"], media=deepcopy(IMAGE))])
|
|
self.valid(data)
|
|
blend = data["payload"]["preview"]["scene_file"]
|
|
blend["verified"] = True
|
|
self.invalid(data)
|
|
blend.update(sha256="a" * 64, blender_version="5.2.2 LTS")
|
|
self.valid(data)
|
|
for key, value in [("sha256", "A" * 64), ("blender_version", ""), ("path", "")]:
|
|
bad = deepcopy(data)
|
|
bad["payload"]["preview"]["scene_file"][key] = value
|
|
self.invalid(bad)
|
|
bad = deepcopy(data)
|
|
bad["payload"]["preview"]["samples"][0]["media"]["kind"] = "blend"
|
|
self.invalid(bad)
|
|
|
|
def test_profile_and_duration_constraints(self):
|
|
for stage in ("10-references", "11-images", "12-motion"):
|
|
profile = read("templates/stages/" + stage + ".json")["payload"]["request"]["render_profile"]
|
|
self.assertEqual((profile["samples"], profile["seed"]), (64, 0))
|
|
for key, value in [("engine", "UNKNOWN"), ("device", "CPU"), ("fps", 0),
|
|
("samples", 0), ("width", 0), ("height", 0), ("seed", -1)]:
|
|
bad = samples()["11-images"]
|
|
bad["payload"]["request"]["render_profile"][key] = value
|
|
self.invalid(bad)
|
|
for duration in (0, -1, 5001):
|
|
bad = samples()["12-motion"]
|
|
bad["payload"]["request"]["clips"][0]["duration_ms"] = duration
|
|
self.invalid(bad)
|
|
cycles = samples()["11-images"]
|
|
cycles["payload"]["request"]["render_profile"].update(engine="CYCLES", device="CPU")
|
|
self.valid(cycles)
|
|
|
|
def test_result_shapes_and_missing_fields(self):
|
|
fixtures = samples()
|
|
fixtures["10-references"]["payload"]["result"] = dict(scene_file=deepcopy(BLEND),
|
|
items=[dict(reference_id="water", object_name="Droplet", media=deepcopy(IMAGE))])
|
|
fixtures["11-images"]["payload"]["result"] = dict(scene_file=deepcopy(BLEND),
|
|
frames=[dict(frame_id="c1-start", media=deepcopy(IMAGE))])
|
|
fixtures["12-motion"]["payload"]["result"] = dict(clips=[dict(cut_id="c1",
|
|
media=dict(IMAGE, kind="video", path="examples/blender-contract/c1.mp4"))])
|
|
for data in fixtures.values():
|
|
self.valid(data)
|
|
if "request" in data["payload"]:
|
|
for key in data["payload"]["request"]:
|
|
bad = deepcopy(data)
|
|
del bad["payload"]["request"][key]
|
|
self.invalid(bad)
|
|
|
|
def test_semantic_references_and_completion(self):
|
|
data = samples()["11-images"]
|
|
context = {"07-cuts": {"payload": {"cuts": [dict(id="c1", start_ms=0, end_ms=4000)]}},
|
|
"10-references": samples()["10-references"]}
|
|
self.assertEqual(semantic_errors(data, context), [])
|
|
self.assertIn("result=null은 완료가 아니다", semantic_errors(data, context, complete=True))
|
|
for change in ("duplicate", "reference", "scene", "aspect", "absolute"):
|
|
bad = deepcopy(data)
|
|
request = bad["payload"]["request"]
|
|
if change == "duplicate":
|
|
request["frames"][1]["id"] = request["frames"][0]["id"]
|
|
elif change == "reference":
|
|
request["frames"][0]["reference_ids"] = ["missing"]
|
|
elif change == "scene":
|
|
request["frames"][1]["scene_name"] = "Other"
|
|
elif change == "aspect":
|
|
request["render_profile"]["width"] = 1920
|
|
else:
|
|
request["scene_path"] = "C:/foreign.blend"
|
|
self.assertTrue(semantic_errors(bad, context), change)
|
|
|
|
def test_blend_media_impersonation(self):
|
|
data = samples()["08-scenes"]
|
|
data["payload"]["preview"] = dict(scene_file=dict(BLEND, verified=True, sha256="a" * 64,
|
|
blender_version="5.2.2"), render_profile=deepcopy(PROFILE), samples=[dict(id="p1", cut_ids=["c1"],
|
|
media=dict(IMAGE, path="examples/blender-contract/wrong.blend", verified=True, width=1080, height=1920))])
|
|
self.valid(data)
|
|
self.assertIn(".blend는 media가 아니다", semantic_errors(data,
|
|
{"07-cuts": {"payload": {"cuts": [{"id": "c1"}]}}}))
|
|
|
|
def test_preview_video_duration_and_actual_aspect(self):
|
|
data = samples()["08-scenes"]
|
|
data["payload"]["preview"] = dict(scene_file=dict(BLEND, verified=True,
|
|
sha256="a" * 64, blender_version="5.2.2"), render_profile=deepcopy(PROFILE),
|
|
samples=[dict(id="p1", cut_ids=["c1"], media=dict(IMAGE, kind="video",
|
|
path="examples/blender-contract/preview.mp4", verified=True,
|
|
duration_ms=5000, width=1080, height=1920))])
|
|
context = {"07-cuts": {"payload": {"cuts": [{"id": "c1"}]}}}
|
|
for duration in (1, 5000):
|
|
with self.subTest(valid_duration=duration):
|
|
data["payload"]["preview"]["samples"][0]["media"]["duration_ms"] = duration
|
|
self.valid(data)
|
|
self.assertEqual(semantic_errors(data, context, complete=True), [])
|
|
for duration in (0, 5001, 6000):
|
|
with self.subTest(invalid_duration=duration):
|
|
bad = deepcopy(data)
|
|
bad["payload"]["preview"]["samples"][0]["media"]["duration_ms"] = duration
|
|
self.invalid(bad)
|
|
for width, height in ((1920, 1080), (1080, 1800)):
|
|
with self.subTest(invalid_aspect=(width, height)):
|
|
bad = deepcopy(data)
|
|
bad["payload"]["preview"]["samples"][0]["media"].update(width=width, height=height)
|
|
self.valid(bad)
|
|
self.assertTrue(semantic_errors(bad, context, complete=True))
|
|
for field in ("duration_ms", "width", "height"):
|
|
with self.subTest(verified_missing_metadata=field):
|
|
bad = deepcopy(data)
|
|
bad["payload"]["preview"]["samples"][0]["media"][field] = None
|
|
self.invalid(bad)
|
|
bad = deepcopy(data)
|
|
bad["payload"]["preview"]["samples"][0]["media"].update(verified=False,
|
|
duration_ms=None, width=None, height=None)
|
|
self.valid(bad)
|
|
self.assertTrue(semantic_errors(bad, context, complete=True))
|
|
bad = samples()["08-scenes"]
|
|
self.valid(bad)
|
|
self.assertTrue(semantic_errors(bad, context, complete=True))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|