feat: add versioned Blender production contracts

This commit is contained in:
NINI
2026-10-09 23:18:07 +09:00
parent b2b37a1105
commit d4f1a800aa
52 changed files with 1601 additions and 224 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
이 영역은 진행 기록과 14단계 결과의 JSON Schema Draft 2020-12 구조를 관리한다. 실제 파일 측정·모델 추론·사용자 승인 실행을 구현하지 않으며 스킬·양식·예제를 임의로 변경하지 않는다.
필드명·타입·단계 종류·상태 열거값을 보존한다. 결과는 공통 버전·입력 참조와 단계별 payload를 갖는다. 진행 기록에는 14단계와 네 검토가 각각 한 번 필요하다. 외부 결과 부재는 null, 미확인 미디어는 verified=false다.
기존 1.0 정의·단계 종류·상태 열거값을 보존하고 1.1 Blender 계약을 버전 분기로 추가한다. .blend는 BlendFile이며 기존 여섯 키 media의 종류를 늘리지 않는다. 결과는 공통 버전·입력 참조와 단계별 payload를 갖는다. 진행 기록에는 14단계와 네 검토가 각각 한 번 필요하다. 외부 결과 부재는 null, 미확인 미디어는 verified=false다.
구조상 유효한 빈 결과가 완료를 뜻하지 않는다. 사용자 응답·실제 파일·식별자 참조·시간 연속성은 별도 의미 확인이 필요하다. 미검수 또는 blocking 발견이 있는 검수 결과를 pass 구조로 허용하지 않는다.
+829 -9
View File
@@ -2,7 +2,10 @@
"type": "object",
"properties": {
"schema_version": {
"const": "1.0"
"enum": [
"1.0",
"1.1"
]
},
"project_id": {
"type": "string",
@@ -1620,6 +1623,687 @@
}
}
]
},
"BlendFile": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"verified": {
"type": "boolean"
},
"sha256": {
"anyOf": [
{
"type": "null"
},
{
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
]
},
"blender_version": {
"anyOf": [
{
"type": "null"
},
{
"type": "string",
"minLength": 1
}
]
}
},
"required": [
"path",
"verified",
"sha256",
"blender_version"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"verified": {
"const": true
}
}
},
"then": {
"properties": {
"path": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"blender_version": {
"type": "string",
"minLength": 1
}
}
}
}
]
},
"RenderProfile": {
"type": "object",
"properties": {
"engine": {
"enum": [
"BLENDER_EEVEE",
"CYCLES"
]
},
"device": {
"enum": [
"CPU",
"GPU"
]
},
"fps": {
"type": "integer",
"minimum": 1
},
"width": {
"type": "integer",
"minimum": 1
},
"height": {
"type": "integer",
"minimum": 1
},
"samples": {
"type": "integer",
"minimum": 1
},
"seed": {
"type": "integer",
"minimum": 0
}
},
"required": [
"engine",
"device",
"fps",
"width",
"height",
"samples",
"seed"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"engine": {
"const": "BLENDER_EEVEE"
}
}
},
"then": {
"properties": {
"device": {
"const": "GPU"
}
}
}
}
]
},
"08-scenes-1.1": {
"type": "object",
"properties": {
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cut_id": {
"type": "string"
},
"description": {
"type": "string"
},
"start_state": {
"type": "string"
},
"end_state": {
"type": "string"
},
"camera": {
"type": "string"
}
},
"required": [
"cut_id",
"description",
"start_state",
"end_state",
"camera"
],
"additionalProperties": false
}
},
"connections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"from_cut_id": {
"type": "string"
},
"to_cut_id": {
"type": "string"
},
"mode": {
"enum": [
"continuous",
"cut"
]
},
"reason": {
"type": "string"
}
},
"required": [
"from_cut_id",
"to_cut_id",
"mode",
"reason"
],
"additionalProperties": false
}
},
"preview": {
"anyOf": [
{
"type": "null"
},
{
"type": "object",
"properties": {
"scene_file": {
"$ref": "#/$defs/BlendFile"
},
"render_profile": {
"$ref": "#/$defs/RenderProfile"
},
"samples": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"cut_ids": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"minItems": 1,
"uniqueItems": true
},
"media": {
"allOf": [
{
"$ref": "#/$defs/media"
},
{
"properties": {
"kind": {
"enum": [
"image",
"video"
]
}
}
}
]
}
},
"required": [
"id",
"cut_ids",
"media"
],
"additionalProperties": false
}
}
},
"required": [
"scene_file",
"render_profile",
"samples"
],
"additionalProperties": false
}
]
}
},
"required": [
"scenes",
"connections",
"preview"
],
"additionalProperties": false
},
"10-references-1.1": {
"type": "object",
"properties": {
"request": {
"type": "object",
"properties": {
"style_reference": {
"const": "references/1.png"
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"kind": {
"enum": [
"style",
"character",
"background",
"object"
]
},
"entity_id": {
"anyOf": [
{
"type": "null"
},
{
"type": "string"
}
]
},
"prompt": {
"type": "string"
}
},
"required": [
"id",
"kind",
"entity_id",
"prompt"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"kind": {
"enum": [
"style",
"object"
]
}
}
},
"then": {
"properties": {
"entity_id": {
"type": "null"
}
}
},
"else": {
"properties": {
"entity_id": {
"type": "string"
}
}
}
}
]
}
},
"engine": {
"const": "Blender"
},
"render_profile": {
"$ref": "#/$defs/RenderProfile"
}
},
"required": [
"style_reference",
"items",
"engine",
"render_profile"
],
"additionalProperties": false
},
"result": {
"anyOf": [
{
"type": "null"
},
{
"type": "object",
"properties": {
"scene_file": {
"$ref": "#/$defs/BlendFile"
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"reference_id": {
"type": "string"
},
"object_name": {
"type": "string",
"minLength": 1
},
"media": {
"allOf": [
{
"$ref": "#/$defs/media"
},
{
"properties": {
"kind": {
"const": "image"
}
}
}
]
}
},
"required": [
"reference_id",
"object_name",
"media"
],
"additionalProperties": false
}
}
},
"required": [
"scene_file",
"items"
],
"additionalProperties": false
}
]
}
},
"required": [
"request",
"result"
],
"additionalProperties": false
},
"11-images-1.1": {
"type": "object",
"properties": {
"request": {
"type": "object",
"properties": {
"frames": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"cut_id": {
"type": "string"
},
"position": {
"enum": [
"start",
"end"
]
},
"prompt": {
"type": "string"
},
"reference_ids": {
"type": "array",
"items": {
"type": "string"
}
},
"scene_name": {
"type": "string",
"minLength": 1
},
"frame_index": {
"type": "integer",
"minimum": 0
}
},
"required": [
"id",
"cut_id",
"position",
"prompt",
"reference_ids",
"scene_name",
"frame_index"
],
"additionalProperties": false
}
},
"engine": {
"const": "Blender"
},
"scene_path": {
"type": "string"
},
"render_profile": {
"$ref": "#/$defs/RenderProfile"
}
},
"required": [
"frames",
"engine",
"scene_path",
"render_profile"
],
"additionalProperties": false
},
"result": {
"anyOf": [
{
"type": "null"
},
{
"type": "object",
"properties": {
"frames": {
"type": "array",
"items": {
"type": "object",
"properties": {
"frame_id": {
"type": "string"
},
"media": {
"allOf": [
{
"$ref": "#/$defs/media"
},
{
"properties": {
"kind": {
"const": "image"
}
}
}
]
}
},
"required": [
"frame_id",
"media"
],
"additionalProperties": false
}
},
"scene_file": {
"$ref": "#/$defs/BlendFile"
}
},
"required": [
"frames",
"scene_file"
],
"additionalProperties": false
}
]
}
},
"required": [
"request",
"result"
],
"additionalProperties": false
},
"12-motion-1.1": {
"type": "object",
"properties": {
"request": {
"type": "object",
"properties": {
"clips": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cut_id": {
"type": "string"
},
"start_frame_id": {
"type": "string"
},
"end_frame_id": {
"type": "string"
},
"duration_ms": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 5000
},
"motion_prompt": {
"type": "string"
},
"scene_name": {
"type": "string",
"minLength": 1
},
"start_frame": {
"type": "integer",
"minimum": 0
},
"end_frame": {
"type": "integer",
"minimum": 0
}
},
"required": [
"cut_id",
"start_frame_id",
"end_frame_id",
"duration_ms",
"motion_prompt",
"scene_name",
"start_frame",
"end_frame"
],
"additionalProperties": false
}
},
"engine": {
"const": "Blender"
},
"scene_path": {
"type": "string"
},
"render_profile": {
"$ref": "#/$defs/RenderProfile"
}
},
"required": [
"clips",
"engine",
"scene_path",
"render_profile"
],
"additionalProperties": false
},
"result": {
"anyOf": [
{
"type": "null"
},
{
"type": "object",
"properties": {
"clips": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cut_id": {
"type": "string"
},
"media": {
"allOf": [
{
"$ref": "#/$defs/media"
},
{
"properties": {
"kind": {
"const": "video"
}
}
}
]
}
},
"required": [
"cut_id",
"media"
],
"additionalProperties": false
}
}
},
"required": [
"clips"
],
"additionalProperties": false
}
]
}
},
"required": [
"request",
"result"
],
"additionalProperties": false
}
},
"oneOf": [
@@ -1699,9 +2383,43 @@
"const": "08-scenes"
},
"payload": {
"$ref": "#/$defs/08-scenes"
"type": "object"
}
}
},
"allOf": [
{
"if": {
"properties": {
"schema_version": {
"const": "1.0"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/08-scenes"
}
}
}
},
{
"if": {
"properties": {
"schema_version": {
"const": "1.1"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/08-scenes-1.1"
}
}
}
}
]
},
{
"properties": {
@@ -1719,9 +2437,43 @@
"const": "10-references"
},
"payload": {
"$ref": "#/$defs/10-references"
"type": "object"
}
}
},
"allOf": [
{
"if": {
"properties": {
"schema_version": {
"const": "1.0"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/10-references"
}
}
}
},
{
"if": {
"properties": {
"schema_version": {
"const": "1.1"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/10-references-1.1"
}
}
}
}
]
},
{
"properties": {
@@ -1729,9 +2481,43 @@
"const": "11-images"
},
"payload": {
"$ref": "#/$defs/11-images"
"type": "object"
}
}
},
"allOf": [
{
"if": {
"properties": {
"schema_version": {
"const": "1.0"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/11-images"
}
}
}
},
{
"if": {
"properties": {
"schema_version": {
"const": "1.1"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/11-images-1.1"
}
}
}
}
]
},
{
"properties": {
@@ -1739,9 +2525,43 @@
"const": "12-motion"
},
"payload": {
"$ref": "#/$defs/12-motion"
"type": "object"
}
}
},
"allOf": [
{
"if": {
"properties": {
"schema_version": {
"const": "1.0"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/12-motion"
}
}
}
},
{
"if": {
"properties": {
"schema_version": {
"const": "1.1"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/12-motion-1.1"
}
}
}
}
]
},
{
"properties": {
+303
View File
@@ -0,0 +1,303 @@
"""저장소 계약의 회귀 검사. 실제 렌더링·승인·미디어 검수는 수행하지 않는다."""
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 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"], "대표 미디어 미검증")
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 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"}]}}}))
if __name__ == "__main__":
unittest.main(verbosity=2)
+112 -36
View File
@@ -2,7 +2,10 @@
"type": "object",
"properties": {
"schema_version": {
"const": "1.0"
"enum": [
"1.0",
"1.1"
]
},
"project_id": {
"type": "string",
@@ -25,40 +28,7 @@
"additionalProperties": false
},
"settings": {
"type": "object",
"properties": {
"aspect_ratio": {
"const": "9:16"
},
"max_duration_ms": {
"const": 180000
},
"max_cut_duration_ms": {
"const": 5000
},
"style_reference": {
"const": "references/1.png"
},
"tts_engine": {
"const": "Qwen3-TTS"
},
"conda_env": {
"const": "qwen3-tts"
},
"image_model": {
"const": "gpt-image-2.5-sunburst"
}
},
"required": [
"aspect_ratio",
"max_duration_ms",
"max_cut_duration_ms",
"style_reference",
"tts_engine",
"conda_env",
"image_model"
],
"additionalProperties": false
"type": "object"
},
"stages": {
"type": "array",
@@ -496,6 +466,112 @@
"blocking"
],
"additionalProperties": false
},
"settings-1.0": {
"type": "object",
"properties": {
"aspect_ratio": {
"const": "9:16"
},
"max_duration_ms": {
"const": 180000
},
"max_cut_duration_ms": {
"const": 5000
},
"style_reference": {
"const": "references/1.png"
},
"tts_engine": {
"const": "Qwen3-TTS"
},
"conda_env": {
"const": "qwen3-tts"
},
"image_model": {
"const": "gpt-image-2.5-sunburst"
}
},
"required": [
"aspect_ratio",
"max_duration_ms",
"max_cut_duration_ms",
"style_reference",
"tts_engine",
"conda_env",
"image_model"
],
"additionalProperties": false
},
"settings-1.1": {
"type": "object",
"properties": {
"aspect_ratio": {
"const": "9:16"
},
"max_duration_ms": {
"const": 180000
},
"max_cut_duration_ms": {
"const": 5000
},
"style_reference": {
"const": "references/1.png"
},
"tts_engine": {
"const": "Qwen3-TTS"
},
"conda_env": {
"const": "qwen3-tts"
},
"visual_engine": {
"const": "Blender"
}
},
"required": [
"aspect_ratio",
"max_duration_ms",
"max_cut_duration_ms",
"style_reference",
"tts_engine",
"conda_env",
"visual_engine"
],
"additionalProperties": false
}
}
},
"allOf": [
{
"if": {
"properties": {
"schema_version": {
"const": "1.0"
}
}
},
"then": {
"properties": {
"settings": {
"$ref": "#/$defs/settings-1.0"
}
}
}
},
{
"if": {
"properties": {
"schema_version": {
"const": "1.1"
}
}
},
"then": {
"properties": {
"settings": {
"$ref": "#/$defs/settings-1.1"
}
}
}
}
]
}