diff --git a/.agents/skills/short-video-images/SKILL.md b/.agents/skills/short-video-images/SKILL.md index ecaff47..bec7741 100644 --- a/.agents/skills/short-video-images/SKILL.md +++ b/.agents/skills/short-video-images/SKILL.md @@ -19,7 +19,7 @@ description: 승인된 컷의 Blender Scene·시작/끝 상태와 논리 프레 frame={id,cut_id,position,prompt,reference_ids,scene_name,frame_index}. 모든 실제 07 컷마다 start/end가 정확히 하나씩 있고 ID가 유일해야 한다. reference_ids는 10의 검증된 요청/결과 ID, scene_name은 비어 있지 않은 실제 Scene 이름, frame_index는 0 이상 논리 프레임이다. 같은 컷 start/end는 같은 Scene을 사용한다. 조사·대본·08 상태와 카메라·09 고정 특징을 prompt에 연결한다. -continuous는 같은 Scene의 앞 end와 뒤 start 상태를 맞추고 실제 PNG를 공유해도 위치별 별도 ID를 유지한다. cut은 서로 다른 Scene의 같은 프레임 번호를 구별하며 캐시 식별에 Scene 이름을 포함한다. 연결을 임의로 바꾸지 않는다. Blender 실제 번호=논리 프레임+1로 평가한다. 영상은 [start,end) 구간이고 끝점 PNG는 end 자체를 평가한다. +1.1 continuous는 동일 Scene의 앞 end와 뒤 start 상태를 맞추고 동일한 실제 PNG 파일을 반드시 공유한다. 위치별 별도 frame ID는 유지한다. 기존 1.0의 선택적 공유 규칙을 소급 변경하지 않는다. cut은 서로 다른 Scene의 같은 프레임 번호를 구별하며 캐시 식별에 Scene 이름을 포함한다. 연결을 임의로 바꾸지 않는다. Blender 실제 번호=논리 프레임+1로 평가한다. 영상은 [start,end) 구간이고 끝점 PNG는 end 자체를 평가한다. result는 null 또는 {scene_file,frames:[{frame_id,media:image}]}다. 요청마다 실제 PNG가 누락·중복·추가 없이 대응하며 실제 .blend의 Scene·프레임·상태·재질·연결을 확인한다. 원본과 RenderProfile은 후속 12의 기준이다. diff --git a/contracts/artifact.schema.json b/contracts/artifact.schema.json index 5ae0c8b..9762015 100644 --- a/contracts/artifact.schema.json +++ b/contracts/artifact.schema.json @@ -1866,6 +1866,27 @@ ] } } + }, + { + "if": { + "properties": { + "kind": { + "const": "video" + } + } + }, + "then": { + "properties": { + "duration_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5000 + } + } + } } ] } diff --git a/contracts/check.py b/contracts/check.py index 5507b28..ada0ec1 100644 --- a/contracts/check.py +++ b/contracts/check.py @@ -83,12 +83,17 @@ def semantic_errors(data, context, complete=False): 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") @@ -239,6 +244,9 @@ class ContractTests(unittest.TestCase): 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"] @@ -298,6 +306,44 @@ class ContractTests(unittest.TestCase): 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) diff --git a/docs/business-rules.md b/docs/business-rules.md index 2804f43..ed82567 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -12,7 +12,7 @@ 장면은 각 컷의 시작·끝·카메라를 정하고 인접 컷의 이어짐 또는 전환 이유를 기록한다. 이어지는 컷은 인물·배경·행동·카메라 상태가 맞아야 한다. 반복 인물·배경의 고정 특징은 유지하고 이야기상 변화는 명시한다. 모든 영상의 고정 인물로 승격하지 않는다. -새 시각 제작은 Blender 교육용 3D이며 사용자 원본의 단순 형태·정돈된 색을 참고한다. 단순 배경·부드러운 조명·선명한 형태를 사용하고 평면 스타일·캠핑 소재·상표를 의무로 복제하지 않는다. 각 컷에 start/end가 하나씩 필요하다. 같은 Scene의 continuous 경계에서 실제 PNG를 공유해도 두 위치의 별도 ID를 남긴다. cut 전환은 Scene 이름을 구분하며 같은 프레임 번호를 같은 상태로 취급하지 않는다. 영상은 그 사이의 설명에 필요한 움직임을 표현한다. +새 시각 제작은 Blender 교육용 3D이며 사용자 원본의 단순 형태·정돈된 색을 참고한다. 단순 배경·부드러운 조명·선명한 형태를 사용하고 평면 스타일·캠핑 소재·상표를 의무로 복제하지 않는다. 각 컷에 start/end가 하나씩 필요하다. 1.1의 continuous 경계에서는 동일 Scene의 앞 end와 뒤 start가 동일한 실제 PNG 파일을 반드시 공유하며 두 위치의 별도 frame ID를 남긴다. 기존 1.0의 선택적 공유 규칙은 그대로 보존한다. cut 전환은 Scene 이름을 구분하며 같은 프레임 번호를 같은 상태로 취급하지 않는다. 영상은 그 사이의 설명에 필요한 움직임을 표현한다. 결과 파일은 실제 존재·형식·메타데이터를 확인해야 한다. 요청·예상·설명용 예제는 검증 결과가 아니다. 파일 부재·부분 결과는 기다리는 상태이고 전체 완료로 처리할 수 없다. 설치 확인만으로 모델 로딩·추론을 확인했다고 쓰지 않는다. diff --git a/docs/contracts.md b/docs/contracts.md index 58ad474..7e5b0b6 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -28,14 +28,14 @@ - BlendFile={path,verified,sha256,blender_version}. path는 루트 상대 문자열, verified는 불리언, sha256은 null 또는 소문자 64자리 16진수, blender_version은 null 또는 비어 있지 않은 문자열이다. verified=true이면 비어 있지 않은 경로·해시·버전이 모두 필요하다. 실제 파일 해시·재열기·버전을 확인하고 기록한다. .blend를 PNG/MP4 media로 기록하지 않는다. - RenderProfile={engine,device,fps,width,height,samples,seed}. engine은 BLENDER_EEVEE/CYCLES, device는 CPU/GPU, EEVEE는 GPU다. fps/width/height/samples는 양의 정수, seed는 0 이상 정수다. 의미 검사에서 9:16을 확인한다. 값은 렌더 요청이며 실제 지원·성공을 뜻하지 않는다. -- 08 payload={scenes,connections,preview}. scenes/connections는 기존 구조다. preview는 null 또는 {scene_file:BlendFile,render_profile:RenderProfile,samples}이고 sample={id,cut_ids,media}다. id는 비어 있지 않고 유일하며 cut_ids는 비어 있지 않은 유일한 기존 컷 ID 배열, media는 실제 PNG 또는 MP4다. 대표 샘플은 scenes 검토 근거로만 사용한다. +- 08 payload={scenes,connections,preview}. scenes/connections는 기존 구조다. preview는 null 또는 {scene_file:BlendFile,render_profile:RenderProfile,samples}이고 sample={id,cut_ids,media}다. id는 비어 있지 않고 유일하며 cut_ids는 비어 있지 않은 유일한 기존 컷 ID 배열, media는 실제 9:16 PNG 또는 MP4다. 대표 영상의 확인된 duration_ms는 0 초과 5000 이하이며 실제 크기·길이가 미확인/null이거나 verified=false인 미디어는 완료 근거가 아니다. 대표 샘플은 scenes 검토 근거로만 사용한다. - 10 request={engine:"Blender",style_reference:"references/1.png",render_profile,items}. item={id,kind,entity_id,prompt}이며 kind는 style/character/background/object, style/object는 entity_id=null, character/background는 실제 09의 해당 ID를 참조한다. result는 null 또는 {scene_file:BlendFile,items}; 결과 item={reference_id,object_name,media:image}. reference_id는 요청 ID, object_name은 비어 있지 않은 실제 .blend 오브젝트/컬렉션 이름이다. 기준 PNG와 실제 자산을 함께 대조한다. - 11 request={engine:"Blender",scene_path,render_profile,frames}. scene_path는 생성할 원본의 루트 상대 경로다. frame={id,cut_id,position,prompt,reference_ids,scene_name,frame_index}; position은 start/end, scene_name은 비어 있지 않은 실제 Scene 이름, frame_index는 0 이상 논리 프레임이다. 같은 컷의 start/end는 같은 Scene을 사용한다. result는 null 또는 {scene_file:BlendFile,frames}; 결과 frame={frame_id,media:image}다. - 12 request={engine:"Blender",scene_path,render_profile,clips}. scene_path는 11의 검증된 실제 .blend 경로이고 render_profile은 11과 동일하다. clip={cut_id,start_frame_id,end_frame_id,duration_ms,motion_prompt,scene_name,start_frame,end_frame}. scene_name과 끝점 참조는 실제 11의 같은 Scene과 일치하고 start_frame/end_frame은 0 이상이며 end_frame>start_frame이다. duration_ms는 미작성 시 null, 작성 시 0 초과 5000 이하 정수이며 실측 07 구간과 같아야 한다. result는 기존 null 또는 {clips:[{cut_id,media:video}]}다. 13/14의 구조는 바꾸지 않는다. 13 timeline={cut_id,video_path,start_ms,end_ms}, captions=[{id,start_ms,end_ms,text}], video는 media, captions_path는 실제 SRT 경로다. 합성은 기존 Remotion + FFmpeg 경로를 사용하며 실행파일의 외부 절대 경로·인증정보는 JSON에 담지 않는다. -continuous는 같은 Scene의 앞 end와 뒤 start PNG를 공유할 수 있으나 위치별 ID는 별도다. cut은 서로 다른 Scene의 같은 frame_index를 구별하며 캐시 식별에는 Scene 이름이 필요하다. 프레임 구간은 [start_frame,end_frame)이다. 이번 물 영상은 50fps·5528개 프레임으로 시간 기준을 유지하고 마지막 끝점 PNG는 논리 프레임 5528을 평가한다. Blender의 실제 번호는 논리 프레임+1이다. 타 주제의 fps·길이를 이 값으로 고정하지 않는다. +1.1의 continuous는 동일 Scene의 앞 end와 뒤 start가 동일한 실제 PNG 파일을 반드시 공유하며 위치별 frame ID는 각각 유지한다. 기존 1.0의 선택적 공유 규칙은 소급 변경하지 않는다. cut은 서로 다른 Scene의 같은 frame_index를 구별하며 캐시 식별에는 Scene 이름이 필요하다. 프레임 구간은 [start_frame,end_frame)이다. 이번 물 영상은 50fps·5528개 프레임으로 시간 기준을 유지하고 마지막 끝점 PNG는 논리 프레임 5528을 평가한다. Blender의 실제 번호는 논리 프레임+1이다. 타 주제의 fps·길이를 이 값으로 고정하지 않는다. media는 {path,kind,verified,duration_ms,width,height}다. kind는 audio/image/video, 미확인 값은 null, verified는 불리언이다. 검증된 음성은 WAV·길이·width/height=null, 이미지는 PNG·크기·duration_ms=null, 영상은 MP4·길이·크기가 필요하다. 시간은 ms 정수이며 완료한 길이는 양수다. 결과의 ID는 요청에 존재해야 한다. diff --git a/templates/stages/10-references.json b/templates/stages/10-references.json index a14c8da..87e61a5 100644 --- a/templates/stages/10-references.json +++ b/templates/stages/10-references.json @@ -15,8 +15,8 @@ "fps": 50, "width": 1080, "height": 1920, - "samples": 32, - "seed": 1 + "samples": 64, + "seed": 0 } }, "result": null diff --git a/templates/stages/11-images.json b/templates/stages/11-images.json index 62fe589..6a588a7 100644 --- a/templates/stages/11-images.json +++ b/templates/stages/11-images.json @@ -14,8 +14,8 @@ "fps": 50, "width": 1080, "height": 1920, - "samples": 32, - "seed": 1 + "samples": 64, + "seed": 0 }, "scene_path": "" }, diff --git a/templates/stages/12-motion.json b/templates/stages/12-motion.json index 0789ae4..5fec226 100644 --- a/templates/stages/12-motion.json +++ b/templates/stages/12-motion.json @@ -14,8 +14,8 @@ "fps": 50, "width": 1080, "height": 1920, - "samples": 32, - "seed": 1 + "samples": 64, + "seed": 0 }, "scene_path": "" },