# -*- coding: utf-8 -*- """ 썸네일 최종 선택 — 규칙/픽셀로 압축한 top-N 후보 중 비전 LLM이 1개 선택 (Phase 2). 배경 (하이브리드 설계): 규칙(태그 매칭) + 픽셀 헤더 필터로 썸네일 후보를 소수(top-3)로 압축한 뒤, 그 소수 중 최종 1컷만 비전 LLM이 이미지를 실제로 보고 고른다. 선택지가 "1슬롯 × 3후보"로 갇혀 있어 전면 LLM 배정의 위험(제약 위반·중복 배정·큰 블라스트 반경)이 없고, 실패 시 규칙 1위로 폴백한다. 이 단계의 실질 가치: Pillow를 쓰지 않아 포기했던 지각적 축을 비전으로 되찾음. - 이미지 속 글자/간판/워터마크가 커버 텍스트 4슬롯과 겹치는지 - 중앙 9:16 크롭 후 주제가 잘리거나 어중간해지는지 - 업종 대표성·클릭 유인 """ from __future__ import annotations import asyncio from pydantic import BaseModel, Field from app.utils.logger import get_logger from app.utils.prompts.chatgpt_prompt import ChatgptService logger = get_logger("thumbnail_vision") # 비전 판정 모델·타임아웃 — 부가 기능이므로 실패해도 규칙 폴백, 파이프라인은 진행 _VISION_MODEL = "gpt-5-mini" _VISION_TIMEOUT = 20.0 class ThumbnailPickOutput(BaseModel): """비전 LLM의 썸네일 선택 출력.""" choice_index: int = Field(..., description="선택한 이미지의 0-기반 인덱스 (첨부 이미지 순서와 동일)") reason: str = Field(..., description="선택 근거 한 줄 (텍스트 충돌/크롭 구도/대표성 관점)") def _build_prompt(candidate_count: int, industry: str, business_name: str) -> str: return f"""당신은 숏폼 광고 영상의 **썸네일(커버) 배경 이미지**를 고르는 전문가입니다. 첨부된 {candidate_count}장의 이미지는 이미 태그·화질 필터를 통과한 후보들입니다. 이 중 커버로 가장 적합한 **1장**을 골라 0-기반 인덱스로 반환하세요. (첫 번째 이미지 = 0, 두 번째 = 1, ...) 업체 정보: {business_name} ({industry} 업종) 썸네일 위에는 아래 4개의 텍스트가 흰 글자로 얹힙니다: - 상단(약 8% 높이): 카테고리 뱃지 - 중앙(약 50%): 업체명 (큰 글자) - 중앙 하단(약 62%): 지역 - 최하단(약 94%): 해시태그 선택 기준 (중요도 순): 0. **업종 대표성·클릭 유인**: 한눈에 어떤 곳인지 전달되고 매력적일 것 1. **텍스트 충돌 회피**: 이미지 속 간판·안내판 글자·워터마크가 위 텍스트 영역과 겹치지 않을 것 2. **크롭 후 구도**: 세로 9:16 중앙 크롭 시 핵심 주제가 잘리지 않고 살아있을 것 3. **가독성**: 텍스트가 얹히는 영역(상/중/하단)이 너무 밝거나 복잡하지 않아 흰 글자가 잘 보일 것 """ async def pick_thumbnail_by_vision( candidates: list[dict], industry: str, business_name: str, ) -> dict | None: """후보 이미지 중 비전 LLM이 최종 1컷을 선택합니다. Args: candidates: [{"image_url": str, "image_tag": dict}, ...] — 규칙/픽셀로 압축한 top-N 후보 (점수 내림차순, 즉 index 0이 규칙 1위). Returns: 선택된 candidate dict. 후보가 1개 이하이거나 호출 실패/타임아웃/무효 인덱스면 None (호출자가 규칙 1위 폴백). """ if len(candidates) < 2: # 선택할 게 없음 — 규칙 1위(있으면)로 폴백 return None urls = [c["image_url"] for c in candidates] prompt = _build_prompt(len(candidates), industry, business_name) chatgpt = ChatgptService(model_type="gpt", timeout=_VISION_TIMEOUT) try: result: ThumbnailPickOutput = await asyncio.wait_for( chatgpt.generate_structured_output_multi_image( prompt_text=prompt, output_format=ThumbnailPickOutput, model=_VISION_MODEL, img_urls=urls, ), timeout=_VISION_TIMEOUT, ) except Exception as e: logger.warning(f"[thumbnail_vision] 비전 선택 실패 — 규칙 폴백: {e}") return None idx = result.choice_index if not (0 <= idx < len(candidates)): logger.warning( f"[thumbnail_vision] 무효 인덱스({idx}, 후보 {len(candidates)}개) — 규칙 폴백" ) return None logger.info( f"[thumbnail_vision] 비전 선택 — index={idx}" f"{' (규칙 1위와 동일)' if idx == 0 else ' (규칙 1위 아님)'}, " f"url={candidates[idx]['image_url']}, reason={result.reason}" ) return candidates[idx]