"""③ motion — 포스터에서 움직일 요소를 골라 i2v 프롬프트를 만든다. 모션 어휘를 고정 카탈로그로 나열했더니 모델이 포스터에 없는 관람차를 그려 넣었다. 생성 모델에게 조건문은 주문서로 읽힌다. 그래서 역할을 나눈다 — **무엇이 있는가**는 VLM이 그림을 보고 답하고, **어떻게 움직이는가**는 검증된 문구가 정한다. """ from pathlib import Path from typing import get_args from PIL import Image from answers.motion_answer import MotionAnswer, MotionElement, MotionKey from models.motion import DroppedMotion, MotionItem, MotionPlan from settings import settings from utils.common_llm import StructuredLLM from utils.image import to_data_uri from utils.prompt import load_prompt, load_prompt_json from utils.qr import qr_box Image.MAX_IMAGE_PIXELS = None POSTER_MAX_SIZE = (1100, 1600) MOTION_PHRASES: dict[str, str] = load_prompt_json("motion_phrase") MOTION_QR_BLOCKS: dict[str, dict[str, str]] = load_prompt_json("motion_qr") MOTION_FRAME = load_prompt("motion_frame") MOTION_VLM_PROMPT = load_prompt("motion_vlm") motion_llm = StructuredLLM("gpt-4o", settings.chatgpt_api_key) _missing_phrases = set(get_args(MotionKey)) - MOTION_PHRASES.keys() if _missing_phrases: raise RuntimeError(f"motion_phrase.json에 문구가 없는 모션 키: {sorted(_missing_phrases)}") def build_prompt(elements: list[MotionElement], has_qr: bool, extra: str = "") -> MotionPlan: """확신 있는 요소만 남기고, 같은 종류는 한 번만 지시한다.""" kept, dropped = [], [] for element in elements: if element.confident: kept.append(MotionItem(motion=element.motion, what=element.what)) else: # 애매하면 안 움직이는 쪽이 기본값 dropped.append(DroppedMotion(motion=element.motion, what=element.what, why="확신없음")) seen: set[str] = set() lines = [] for item in kept: if item.motion in seen: continue seen.add(item.motion) lines.append(MOTION_PHRASES[item.motion]) body = "\n\n".join(lines) if extra: # 포스터별 고정 지시 body += "\n\n" + extra.strip() qr_blocks = MOTION_QR_BLOCKS["present" if has_qr else "absent"] return MotionPlan( prompt=MOTION_FRAME.format(body=body, **qr_blocks), has_qr=has_qr, kept=kept, dropped=dropped, ) async def plan_motion(poster: Path | Image.Image, *, has_qr: bool | None = None, force_motions: list[str] | None = None, extra: str = "") -> MotionPlan: """force_motions를 주면 VLM을 건너뛴다 — 검수 게이트의 수동 추가·제거용. 빈 리스트면 0종.""" source = (Image.open(poster) if isinstance(poster, Path) else poster).convert("RGB") if force_motions is None: answer = await motion_llm.ask_with_images( MotionAnswer, MOTION_VLM_PROMPT, [("포스터:", to_data_uri(source, POSTER_MAX_SIZE))]) elements = answer.elements else: unknown = [key for key in force_motions if key not in MOTION_PHRASES] if unknown: raise ValueError(f"미지의 모션 키: {unknown} — 허용: {', '.join(MOTION_PHRASES)}") elements = [MotionElement(motion=key, what="검수 게이트 수동 지정", confident=True) for key in force_motions] # QR 언급은 주문서다 — 없는 포스터에 보존 문구를 넣으면 모델이 QR을 그려 넣는다 if has_qr is None: has_qr = qr_box(source) is not None return build_prompt(elements, has_qr, extra)