87 lines
3.6 KiB
Python
87 lines
3.6 KiB
Python
"""③ 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)
|