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