playreel/backend/motion.py
2026-09-02 09:37:58 +09:00

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)