playreel/backend/narration.py

87 lines
3.6 KiB
Python

"""② narration_text — 나레이션 3문장 + voice + BGM 스타일 + 메타태그.
앞 단계가 읽은 글자는 넘기지 않는다. 좌표만 넘기고 글자는 확대 크롭에서 직접 읽게 한다.
상류 모델의 오독을 프롬프트에 넣으면 하류가 이미지를 다시 안 보고 그대로 베낀다.
"""
import json
from pathlib import Path
from PIL import Image
from pydantic import ValidationError
from answers.narration_answer import NarrationAnswer
from models.detect import Box, Regions
from settings import settings
from utils.common_llm import StructuredLLM
from utils.image import to_data_uri
from utils.prompt import load_prompt
Image.MAX_IMAGE_PIXELS = None
# 글자를 읽어야 하는 영역만 잘라 확대해 따로 보여준다 (장식 서체 오독 방지)
CROP_LABELS = (("② 제목 영역 확대", "title"),
("③ 일시 영역 확대", "datetime"),
("④ 장소 영역 확대", "place"))
CROP_LONG_EDGE = 1024
POSTER_MAX_SIZE = (1024, 1024)
NARRATION_TEMPERATURE = 0.6
NARRATION_SYSTEM_PROMPT = load_prompt("narration")
NARRATION_RETRY_PROMPT = load_prompt("narration_retry")
narration_llm = StructuredLLM("gpt-4o", settings.chatgpt_api_key)
def crop_region(poster: Image.Image, box: Box, pad: float = 0.03) -> Image.Image | None:
"""영역만 잘라 확대한다. 전체 한 장으로는 장식 서체의 획 간격을 놓친다."""
width, height = poster.size
left = int(max(0.0, box.x0 - pad) * width)
right = int(min(1.0, box.x1 + pad) * width)
top = int(max(0.0, box.y0 - pad) * height)
bottom = int(min(1.0, box.y1 + pad) * height)
if right - left < 16 or bottom - top < 16:
return None
crop = poster.crop((left, top, right, bottom))
scale = CROP_LONG_EDGE / max(crop.size)
if scale > 1:
crop = crop.resize((round(crop.width * scale), round(crop.height * scale)), Image.LANCZOS)
return crop
def build_images(poster: Image.Image, regions: Regions) -> list[tuple[str, str]]:
images = [("① 포스터 전체:", to_data_uri(poster, POSTER_MAX_SIZE))]
for label, kind in CROP_LABELS:
box = regions.regions.get(kind)
crop = crop_region(poster, box) if box else None
if crop is None:
continue
images.append((f"{label} (여기 적힌 글자를 한 자씩 읽어라):",
to_data_uri(crop, (CROP_LONG_EDGE, CROP_LONG_EDGE))))
return images
def build_prompt(regions: Regions) -> str:
"""좌표만 넘긴다 — 앞 단계가 읽은 text는 뺀다."""
slim = {
"title_style": regions.title_style,
"regions": {kind: {"x0": box.x0, "x1": box.x1, "y0": box.y0, "y1": box.y1}
for kind, box in regions.regions.items()},
}
return "영역 분석 JSON:\n" + json.dumps(slim, ensure_ascii=False)
async def generate_narration(poster: Path | Image.Image, regions: Regions) -> NarrationAnswer:
"""검증 실패 시 사유를 피드백으로 넣어 1회만 재생성한다. 2회째 실패는 그대로 올린다."""
source = (Image.open(poster) if isinstance(poster, Path) else poster).convert("RGB")
images = build_images(source, regions)
prompt = build_prompt(regions)
try:
return await narration_llm.ask_with_images(
NarrationAnswer, prompt, images,
system=NARRATION_SYSTEM_PROMPT, temperature=NARRATION_TEMPERATURE)
except ValidationError as first_error:
return await narration_llm.ask_with_images(
NarrationAnswer, prompt + NARRATION_RETRY_PROMPT.format(error=first_error), images,
system=NARRATION_SYSTEM_PROMPT, temperature=NARRATION_TEMPERATURE)