"""③ 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)