Compare commits

..

No commits in common. "main" and "feature-subtitle" have entirely different histories.

36 changed files with 1466 additions and 3659 deletions

View File

@ -139,13 +139,6 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
raise raise
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
if getattr(e, "status_code", 500) < 500:
logger.warning(
f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
else:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
logger.error( logger.error(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, " f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "

View File

@ -2,9 +2,7 @@ import json
import time import time
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from typing import Literal, Optional from typing import Optional
from pydantic import BaseModel
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
import aiofiles import aiofiles
@ -41,8 +39,6 @@ from app.utils.nvMapPwScraper import NvMapPwScraper
from app.utils.prompts.prompts import marketing_prompt from app.utils.prompts.prompts import marketing_prompt
from app.utils.address_parser import extract_region_from_address from app.utils.address_parser import extract_region_from_address
from app.utils.autotag import autotag_images from app.utils.autotag import autotag_images
from app.utils.image_filter import filter_marketing_images, assemble_images
from app.video.services.video import get_image_tags_by_task_id
from config import MEDIA_ROOT from config import MEDIA_ROOT
logger = get_logger("home") logger = get_logger("home")
@ -52,19 +48,18 @@ router = APIRouter()
@router.get( @router.get(
"/search/accommodation", "/search/accommodation",
summary="장소 자동완성 검색 (숙박/음식점 등)", summary="숙박/펜션 자동완성 검색",
description=""" description="""
네이버 지역 검색 API를 이용한 장소 자동완성 검색입니다. 네이버 지역 검색 API를 이용한 숙박/펜션 자동완성 검색입니다.
## 요청 파라미터 ## 요청 파라미터
- **query**: 검색어 (필수) - **query**: 검색어 (필수)
- **category**: 카테고리
## 반환 정보 ## 반환 정보
- **query**: 검색어 - **query**: 검색어
- **count**: 검색 결과 (최대 10) - **count**: 검색 결과 (최대 10)
- **items**: 검색 결과 목록 - **items**: 검색 결과 목록
- **title**: 소명 (HTML 태그 포함 가능) - **title**: 소명 (HTML 태그 포함 가능)
- **address**: 지번 주소 - **address**: 지번 주소
- **roadAddress**: 도로명 주소 - **roadAddress**: 도로명 주소
""", """,
@ -77,7 +72,7 @@ router = APIRouter()
async def search_accommodation( async def search_accommodation(
query: str, query: str,
) -> AccommodationSearchResponse: ) -> AccommodationSearchResponse:
"""장소 자동완성 검색""" """숙박/펜션 자동완성 검색"""
results = await naver_search_client.search_accommodation( results = await naver_search_client.search_accommodation(
query=query, query=query,
display=10, display=10,
@ -96,44 +91,6 @@ def _extract_region_from_address(road_address: str | None, jibun_address: str |
return extract_region_from_address(road_address, jibun_address) return extract_region_from_address(road_address, jibun_address)
class _IndustryOutput(BaseModel):
industry: Literal[
"stay", "restaurant", "cafe", "salon", "clinic", "fitness", "academy", "attraction", "general"
]
async def _resolve_industry(category: str, customer_name: str = "") -> str:
"""업체를 통합 프롬프트의 9개 industry enum 중 하나로 AI 분류.
분류 근거는 카테고리를 우선 사용하고, 카테고리가 없으면 업체명으로 분류한다.
근거가 없으면 문자열 반환. API 장애 예외 전파(하위 분석도 동일 API 의존).
"""
if category:
basis_label, basis_value = "네이버 지도 카테고리", category
elif customer_name:
basis_label, basis_value = "업체명", customer_name
else:
return ""
chatgpt = ChatgptService()
prompt = (
f"{basis_label}: '{basis_value}'\n"
"위 정보를 바탕으로 이 업체를 다음 업종 중 가장 적합한 하나로 분류하세요: "
"stay(숙박/펜션/호텔), restaurant(음식점), cafe(카페/디저트), "
"salon(미용실/네일/뷰티), clinic(병원/의원/치과), fitness(헬스/필라테스/요가), "
"academy(학원/교습소), attraction(관광/체험/액티비티/축제/행사). "
"위 8개 중 어느 것에도 명확히 해당하지 않는 경우에만 general(기타/범용 업종)로 분류하세요. "
"유사한 업종이 있으면 general 대신 그 업종을 우선 선택하세요."
)
result = await chatgpt._call_pydantic_output_chat_completion(
prompt=prompt,
output_format=_IndustryOutput,
model="gpt-4o-mini",
img_url=None,
image_detail_high=False,
)
return result.industry
@router.post( @router.post(
"/crawling", "/crawling",
summary="네이버 지도 크롤링", summary="네이버 지도 크롤링",
@ -249,12 +206,12 @@ async def _crawling_logic(
) )
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
image_count = len(scraper.image_link_list) if scraper.image_link_list else 0
logger.info( logger.info(
f"[crawling] Step 1 완료 - 업체사진 {len(scraper.owner_images or [])}개, " f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)"
f"보충사진 {len(scraper.extra_photo_urls or [])}개 ({step1_elapsed:.1f}ms)"
) )
# ========== Step 2: 정보 가공 (industry 선행 계산) ========== # ========== Step 2: 정보 가공 ==========
step2_start = time.perf_counter() step2_start = time.perf_counter()
logger.info("[crawling] Step 2: 정보 가공 시작...") logger.info("[crawling] Step 2: 정보 가공 시작...")
@ -265,109 +222,38 @@ async def _crawling_logic(
road_address = scraper.base_info.get("roadAddress", "") road_address = scraper.base_info.get("roadAddress", "")
jibun_address = scraper.base_info.get("address", "") jibun_address = scraper.base_info.get("address", "")
customer_name = scraper.base_info.get("name", "") customer_name = scraper.base_info.get("name", "")
category = scraper.base_info.get("category", "")
region = _extract_region_from_address(road_address, jibun_address) region = _extract_region_from_address(road_address, jibun_address)
try:
# industry는 마케팅 적합성 필터(Step 3)와 ProcessedInfo 양쪽에 필요하므로
# 이미지 필터링보다 먼저 계산한다.
industry = await _resolve_industry(category, customer_name)
except ChatGPTResponseError as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"업종 분류 중 ChatGPT 오류가 발생했습니다: {e.error_message}",
)
except Exception as e:
logger.error(f"[crawling] Step 2 FAILED - 업종 분류 오류: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="업종 분류 중 오류가 발생했습니다.",
)
processed_info = ProcessedInfo( processed_info = ProcessedInfo(
customer_name=customer_name, customer_name=customer_name,
region=region, region=region,
detail_region_info=road_address or jibun_address or "", detail_region_info=road_address or jibun_address or "",
industry=industry,
) )
step2_elapsed = (time.perf_counter() - step2_start) * 1000 step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info( logger.info(
f"[crawling] Step 2 완료 - {customer_name}, {region}, " f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)"
f"category={category!r}, industry={industry!r} ({step2_elapsed:.1f}ms)"
) )
# ========== Step 3: 이미지 마케팅 적합성 필터링 ========== # ========== Step 3: ChatGPT 마케팅 분석 ==========
# 업체 사진이 SUPPLEMENT_THRESHOLD(30장) 이상이면 보충이 불필요하므로
# 방문자 사진 필터링(Gemini 호출) 자체를 건너뛴다.
step3_start = time.perf_counter() step3_start = time.perf_counter()
owner_images = scraper.owner_images or [] logger.info("[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
extra_photo_urls = scraper.extra_photo_urls or []
if len(owner_images) >= NvMapScraper.SUPPLEMENT_THRESHOLD:
# 업체 제공 사진은 필터링 면제 대상이므로 MAX_IMAGES 상한 없이 수집분을 전부 사용한다
# (MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용.
# 수집 자체는 NvMapScraper.BIZ_MAX_PAGES가 상한이며 도달 시 scraper가 warning을 남긴다).
scraper.image_link_list = list(owner_images)
step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.info(
f"[crawling] Step 3 SKIP - 업체 사진 {len(owner_images)}"
f"{NvMapScraper.SUPPLEMENT_THRESHOLD}장 → 방문자 사진 필터링 생략, 업체 사진만 사용"
)
else:
try:
extra_pass_flags = await filter_marketing_images(
[img["original"] for img in extra_photo_urls], industry
)
except Exception as e:
# logger.error(f"[crawling] Step 3 FAILED - 이미지 필터링 중 오류: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="이미지 마케팅 적합성 필터링 중 오류가 발생했습니다.",
)
scraper.image_link_list = assemble_images(
owner_images, extra_photo_urls, extra_pass_flags, NvMapScraper.MAX_IMAGES
)
passed_count = sum(extra_pass_flags)
step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.info(
f"[crawling] Step 3 완료 - 방문자 사진 {len(extra_photo_urls)}장 중 "
f"{passed_count}장 통과 → 최종 이미지 {len(scraper.image_link_list)}"
f"({step3_elapsed:.1f}ms)"
)
if not scraper.image_link_list:
logger.warning("[crawling] Step 3 - 필터링 후 사용 가능 이미지가 0장입니다.")
# ========== Step 4: ChatGPT 마케팅 분석 ==========
step4_start = time.perf_counter()
logger.info("[crawling] Step 4: ChatGPT 마케팅 분석 시작...")
try: try:
# Step 4-1: ChatGPT 서비스 초기화 및 입력 데이터 구성 # Step 3-1: ChatGPT 서비스 초기화 및 입력 데이터 구성
chatgpt_service = ChatgptService() chatgpt_service = ChatgptService()
# 메뉴 전달은 잠정 보류 — 사이드 메뉴(볶음밥·냉면 등)가 분석에 과도한 영향을 주는
# 문제로 프롬프트에서 {menu_info}를 제거함. 크롤링(scraper.menu_info)은 유지 중이므로
# 주력/사이드 구분 방안이 정리되면 아래 주석을 되살려 재전달할 것.
# menus = scraper.menu_info or []
# menus = sorted(menus, key=lambda m: not m.get("recommend"))[:15]
# menu_info = ", ".join(m["name"] for m in menus if m.get("name"))
top_keywords = (scraper.voted_keyword_stats or [])[:5]
input_marketing_data = { input_marketing_data = {
"customer_name": customer_name, "customer_name": customer_name,
"region": region, "region": region,
"detail_region_info": road_address or "", "detail_region_info": road_address or "",
"industry": industry, # 통합 프롬프트 내부 분기용 업종 enum
"category": category, # 네이버 지도 원본 카테고리 텍스트
"facility_info": scraper.facility_info or "",
"voted_keywords": ", ".join(kw["displayName"] for kw in top_keywords),
# "menu_info": menu_info, # 실제 판매 품목 (셀링포인트 구체화용)
} }
# Step 4-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환 # Step 3-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환
marketing_analysis = await chatgpt_service.generate_structured_output( marketing_analysis = await chatgpt_service.generate_structured_output(
marketing_prompt, input_marketing_data marketing_prompt, input_marketing_data
) )
# Step 4-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장) # Step 3-3: 분석 결과 DB 저장
marketing_intel = MarketingIntel( marketing_intel = MarketingIntel(
place_id=scraper.place_id, place_id=scraper.place_id,
intel_result=marketing_analysis.model_dump(), intel_result=marketing_analysis.model_dump(),
@ -378,26 +264,26 @@ async def _crawling_logic(
m_id = marketing_intel.id m_id = marketing_intel.id
logger.debug(f"[MarketingPrompt] INSERT place_id={marketing_intel.place_id} id={marketing_intel.id}") logger.debug(f"[MarketingPrompt] INSERT place_id={marketing_intel.place_id} id={marketing_intel.id}")
step4_elapsed = (time.perf_counter() - step4_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.info( logger.info(
f"[crawling] Step 4 완료 - 마케팅 분석 성공 ({step4_elapsed:.1f}ms)" f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)"
) )
except ChatGPTResponseError as e: except ChatGPTResponseError as e:
step4_elapsed = (time.perf_counter() - step4_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.error( logger.error(
f"[crawling] Step 4 FAILED - ChatGPT Error: status={e.status}, " f"[crawling] Step 3 FAILED - ChatGPT Error: status={e.status}, "
f"code={e.error_code}, message={e.error_message} ({step4_elapsed:.1f}ms)" f"code={e.error_code}, message={e.error_message} ({step3_elapsed:.1f}ms)"
) )
marketing_analysis = None marketing_analysis = None
gpt_status = "failed" gpt_status = "failed"
except Exception as e: except Exception as e:
step4_elapsed = (time.perf_counter() - step4_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.error( logger.error(
f"[crawling] Step 4 FAILED - GPT 마케팅 분석 중 오류: {e} ({step4_elapsed:.1f}ms)" f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)"
) )
logger.exception("[crawling] Step 4 상세 오류:") logger.exception("[crawling] Step 3 상세 오류:")
marketing_analysis = None marketing_analysis = None
gpt_status = "failed" gpt_status = "failed"
else: else:
@ -414,9 +300,7 @@ async def _crawling_logic(
if scraper.base_info: if scraper.base_info:
logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms") logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
if "step3_elapsed" in locals(): if "step3_elapsed" in locals():
logger.info(f"[crawling] - Step 3 (이미지 필터링): {step3_elapsed:.1f}ms") logger.info(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
if "step4_elapsed" in locals():
logger.info(f"[crawling] - Step 4 (GPT 분석): {step4_elapsed:.1f}ms")
return { return {
"status": gpt_status if 'gpt_status' in locals() else "completed", "status": gpt_status if 'gpt_status' in locals() else "completed",
@ -424,8 +308,7 @@ async def _crawling_logic(
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0, "image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
"processed_info": processed_info, "processed_info": processed_info,
"marketing_analysis": marketing_analysis, "marketing_analysis": marketing_analysis,
"m_id": m_id, "m_id" : m_id
"industry": industry if 'industry' in locals() else "",
} }
@ -439,7 +322,6 @@ async def _crawling_logic(
## 요청 필드 ## 요청 필드
- **customer_name**: 업체명 / 브랜드명 (필수) - **customer_name**: 업체명 / 브랜드명 (필수)
- **address**: 도로명 또는 지번 주소 (필수) - **address**: 도로명 또는 지번 주소 (필수)
- **category**: 업종/카테고리 자유 입력 (선택, : 펜션, 카페). 비우면 업체명 기반 AI 분류
## 반환 정보 ## 반환 정보
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info) - **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
@ -464,17 +346,10 @@ async def manual_marketing(
# Step 2: GPT API 호출 → 마케팅 분석 결과 생성 # Step 2: GPT API 호출 → 마케팅 분석 결과 생성
# place_id 없이 업체명+주소만으로 분석 (크롤링 없이 직접 입력된 경우) # place_id 없이 업체명+주소만으로 분석 (크롤링 없이 직접 입력된 경우)
chatgpt_service = ChatgptService() chatgpt_service = ChatgptService()
# 크롤링 경로와 동일하게 category를 우선 사용하고, 없으면 업체명 기반 AI 분류
industry = await _resolve_industry(
request_body.category, request_body.store_name
)
processed_info.industry = industry
input_marketing_data = { input_marketing_data = {
"customer_name": request_body.store_name, "customer_name": request_body.store_name,
"region": region, "region": region,
"detail_region_info": request_body.address, "detail_region_info": request_body.address,
"industry": industry, # 통합 프롬프트 내부 분기용 업종 enum
"category": request_body.category, # 사용자 입력 원본 카테고리 텍스트 (세부 묘사 참고용)
} }
marketing_analysis = await chatgpt_service.generate_structured_output( marketing_analysis = await chatgpt_service.generate_structured_output(
marketing_prompt, input_marketing_data marketing_prompt, input_marketing_data
@ -511,7 +386,6 @@ async def manual_marketing(
processed_info=processed_info, processed_info=processed_info,
marketing_analysis=marketing_analysis, marketing_analysis=marketing_analysis,
m_id=m_id, m_id=m_id,
industry=industry,
) )
@ -675,10 +549,6 @@ async def upload_images_blob(
default=None, default=None,
description="이미지 바이너리 파일 목록", description="이미지 바이너리 파일 목록",
), ),
industry: str = Form(
default="",
description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 크롤링 응답의 industry 값을 그대로 전달",
),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> ImageUploadResponse: ) -> ImageUploadResponse:
"""이미지 업로드 (URL + Azure Blob Storage) """이미지 업로드 (URL + Azure Blob Storage)
@ -880,21 +750,9 @@ async def upload_images_blob(
image_urls = [img.img_url for img in result_images] image_urls = [img.img_url for img in result_images]
logger.info(f"[image_tagging] START - task_id: {task_id}") logger.info(f"[image_tagging] START - task_id: {task_id}")
await tagging_images(image_urls, industry=industry, clear_old_tags=True) await tagging_images(image_urls, clear_old_tags=True)
logger.info(f"[image_tagging] Done - task_id: {task_id}") logger.info(f"[image_tagging] Done - task_id: {task_id}")
# 태깅 직후 영상 생성에 사용 가능한 이미지가 하나도 없으면 조기에 실패시킨다.
# (여기서 걸러지지 않으면 훨씬 나중인 영상 생성 단계에서야 슬롯 미배정으로 발견됨)
# marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되었으므로 여기서는 재필터링하지 않는다.
taged_image_list = await get_image_tags_by_task_id(task_id)
logger.info(f"태깅된 이미지: {len(taged_image_list)}개 - task_id: {task_id}")
if not taged_image_list:
logger.error(f"[image_tagging] 영상 생성에 적합한 이미지가 없음 - task_id: {task_id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="영상 생성에 적합한 이미지가 없습니다. 다른 이미지로 다시 업로드해주세요.",
)
total_time = time.perf_counter() - request_start total_time = time.perf_counter() - request_start
logger.info( logger.info(
f"[upload_images_blob] SUCCESS - task_id: {task_id}, " f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
@ -914,7 +772,6 @@ async def upload_images_blob(
async def tagging_images( async def tagging_images(
image_urls : list[str], image_urls : list[str],
industry: str = "",
clear_old_tags : bool = False clear_old_tags : bool = False
) -> None: ) -> None:
# 1. 조회 # 1. 조회
@ -940,17 +797,12 @@ async def tagging_images(
await session.commit() await session.commit()
if null_imts: if null_imts:
tag_datas = await autotag_images([img.img_url for img in null_imts], industry=industry) tag_datas = await autotag_images([img.img_url for img in null_imts])
# print(tag_datas) print(tag_datas)
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
for tag, tag_data in zip(null_imts, tag_datas): for tag, tag_data in zip(null_imts, tag_datas):
if isinstance(tag_data, Exception): if isinstance(tag_data, Exception):
# 태깅 실패 이미지는 img_tag가 NULL로 남아 영상 생성 풀에서 제외됨
logger.warning(
f"[tagging_images] 이미지 태깅 최종 실패 - url: {tag.img_url}, "
f"error: {type(tag_data).__name__}: {tag_data}"
)
continue continue
tag.img_tag = tag_data.model_dump(mode="json") tag.img_tag = tag_data.model_dump(mode="json")
session.add(tag) session.add(tag)

View File

@ -114,13 +114,6 @@ class Project(Base):
comment="마케팅 인텔리전스 결과 정보 저장", comment="마케팅 인텔리전스 결과 정보 저장",
) )
industry: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="",
comment="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)",
)
language: Mapped[str] = mapped_column( language: Mapped[str] = mapped_column(
String(50), String(50),
nullable=False, nullable=False,
@ -314,12 +307,6 @@ class MarketingIntel(Base):
comment="자막 정보 생성 결과물", comment="자막 정보 생성 결과물",
) )
image_match : Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=True,
comment="이미지 슬롯 배정 결과물 — {슬롯명: image_url, 'audio-music': music_url, ...}",
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
@ -370,10 +357,8 @@ class ImageTag(Base):
) )
img_tag: Mapped[dict[str, Any]] = mapped_column( img_tag: Mapped[dict[str, Any]] = mapped_column(
# none_as_null=True: ORM에서 None 대입 시 JSON 리터럴 null이 아닌 SQL NULL로 저장. JSON,
# 미지정 시 JSON null이 저장되어 `img_tag.is_not(None)` 필터를 통과하는 버그가 있었음.
JSON(none_as_null=True),
nullable=True, nullable=True,
default=None, default=False,
comment="태그 JSON", comment="태그 JSON",
) )

View File

@ -78,7 +78,6 @@ class ProcessedInfo(BaseModel):
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)") customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)") region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)") detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
# class MarketingAnalysisDetail(BaseModel): # class MarketingAnalysisDetail(BaseModel):
@ -98,12 +97,6 @@ class ProcessedInfo(BaseModel):
# selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록") # selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
class ImageListItem(BaseModel):
"""크롤링 이미지 아이템 (미리보기 URL + 원본 URL)"""
preview: str = Field(..., description="미리보기용 CDN URL")
original: str = Field(..., description="업로드용 원본 URL")
class CrawlingResponse(BaseModel): class CrawlingResponse(BaseModel):
"""크롤링 응답 스키마""" """크롤링 응답 스키마"""
@ -111,7 +104,7 @@ class CrawlingResponse(BaseModel):
json_schema_extra={ json_schema_extra={
"example": { "example": {
"status": "completed", "status": "completed",
"image_list": [{"preview": "https://example.com/image1.jpg", "original": "https://example.com/image1.jpg"}], "image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"],
"image_count": 2, "image_count": 2,
"processed_info": { "processed_info": {
"customer_name": "스테이 머뭄", "customer_name": "스테이 머뭄",
@ -240,7 +233,7 @@ class CrawlingResponse(BaseModel):
default="completed", default="completed",
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)" description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
) )
image_list: Optional[list[ImageListItem]] = Field(None, description="이미지 목록 (preview/original URL)") image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
image_count: Optional[int] = Field(None, description="이미지 개수") image_count: Optional[int] = Field(None, description="이미지 개수")
processed_info: Optional[ProcessedInfo] = Field( processed_info: Optional[ProcessedInfo] = Field(
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)" None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
@ -249,7 +242,6 @@ class CrawlingResponse(BaseModel):
None, description="마케팅 분석 결과 . 실패 시 null" None, description="마케팅 분석 결과 . 실패 시 null"
) )
m_id : int = Field(..., description="마케팅 분석 결과 ID") m_id : int = Field(..., description="마케팅 분석 결과 ID")
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
class ManualMarketingRequest(BaseModel): class ManualMarketingRequest(BaseModel):
@ -260,14 +252,12 @@ class ManualMarketingRequest(BaseModel):
"example": { "example": {
"store_name": "스테이 머뭄", "store_name": "스테이 머뭄",
"address": "전북특별자치도 군산시 절골길 18", "address": "전북특별자치도 군산시 절골길 18",
"category": "펜션",
} }
} }
) )
store_name: str = Field(..., description="업체명 / 브랜드명") store_name: str = Field(..., description="업체명 / 브랜드명")
address: str = Field(..., description="도로명 또는 지번 주소") address: str = Field(..., description="도로명 또는 지번 주소")
category: str = Field(default="", description="업체 업종/카테고리 자유 입력 (예: 펜션, 카페). 크롤링 경로와 동일하게 AI가 8개 industry enum으로 자동 분류하는 데 사용. 비우면 업체명 기반 AI 분류")
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):

View File

@ -32,7 +32,7 @@ class NaverSearchClient:
display: int = 5, display: int = 5,
) -> List[dict]: ) -> List[dict]:
""" """
장소 검색 (숙박, 음식점 ) 숙박/펜션 검색
Args: Args:
query: 검색어 query: 검색어
@ -41,7 +41,8 @@ class NaverSearchClient:
Returns: Returns:
검색 결과 리스트 (address, roadAddress, title) 검색 결과 리스트 (address, roadAddress, title)
""" """
search_query = query # 숙박/펜션 카테고리 검색을 위해 쿼리에 키워드 추가
search_query = f"{query} 숙박"
headers = { headers = {
"X-Naver-Client-Id": self.client_id, "X-Naver-Client-Id": self.client_id,

View File

@ -42,8 +42,7 @@ from app.lyric.schemas.lyric import (
LyricStatusResponse, LyricStatusResponse,
SubtitleStatusResponse, SubtitleStatusResponse,
) )
from app.lyric.worker.lyric_task import generate_lyric_background from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
from app.video.worker.creative_assets_task import generate_subtitle_background
from app.utils.prompts.chatgpt_prompt import ChatgptService from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse, get_paginated from app.utils.pagination import PaginatedResponse, get_paginated
@ -161,7 +160,6 @@ async def get_lyric_by_task_id(
project_id=lyric.project_id, project_id=lyric.project_id,
status=lyric.status, status=lyric.status,
lyric_result=lyric.lyric_result, lyric_result=lyric.lyric_result,
genre=lyric.genre,
created_at=lyric.created_at, created_at=lyric.created_at,
) )
@ -271,21 +269,15 @@ async def generate_lyric(
step1_start = time.perf_counter() step1_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...") logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
timing_rules = {
"60s" : """
812 lines
Full verse flow, immersive mood
"""
}
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id)) marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id))
marketing_intel = marketing_intel_result.scalar_one_or_none() marketing_intel = marketing_intel_result.scalar_one_or_none()
# 자동 선택(genre 미지정) 재생성 시, 직전에 사용된 장르를 조회해 이번엔 다른 장르가 나오도록 제외 처리
exclude_genre = ""
if not request_body.genre:
previous_lyric_result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id, Lyric.genre.is_not(None))
.order_by(Lyric.created_at.desc())
.limit(1)
)
previous_lyric = previous_lyric_result.scalar_one_or_none()
if previous_lyric:
exclude_genre = previous_lyric.genre
lyric_input_data = { lyric_input_data = {
"customer_name" : request_body.customer_name, "customer_name" : request_body.customer_name,
@ -293,14 +285,9 @@ async def generate_lyric(
"detail_region_info" : request_body.detail_region_info or "", "detail_region_info" : request_body.detail_region_info or "",
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False), "marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
"language" : request_body.language, "language" : request_body.language,
"genre": request_body.genre or "", # 비어있으면 GPT가 가사 무드에 맞춰 장르를 직접 추천 "timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
"exclude_genre": exclude_genre, # 자동 선택 재생성 시 직전 장르 제외
"industry": request_body.industry, # 크롤 응답에서 받아 전달된 업종 enum
} }
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
selected_lyric_prompt = lyric_prompt
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") #logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
@ -326,8 +313,7 @@ async def generate_lyric(
detail_region_info=request_body.detail_region_info, detail_region_info=request_body.detail_region_info,
language=request_body.language, language=request_body.language,
user_uuid=current_user.user_uuid, user_uuid=current_user.user_uuid,
marketing_intelligence=request_body.m_id, marketing_intelligence = request_body.m_id
industry=request_body.industry,
) )
session.add(project) session.add(project)
await session.commit() await session.commit()
@ -341,7 +327,7 @@ async def generate_lyric(
step3_start = time.perf_counter() step3_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...") logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
estimated_prompt = selected_lyric_prompt.build_prompt(lyric_input_data) estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
lyric = Lyric( lyric = Lyric(
project_id=project.id, project_id=project.id,
task_id=task_id, task_id=task_id,
@ -349,7 +335,6 @@ async def generate_lyric(
lyric_prompt=estimated_prompt, lyric_prompt=estimated_prompt,
lyric_result=None, lyric_result=None,
language=request_body.language, language=request_body.language,
genre=request_body.genre or None, # 자동 선택인 경우 GPT 응답 완료 후 채워짐
) )
session.add(lyric) session.add(lyric)
await session.commit() await session.commit()
@ -373,7 +358,7 @@ async def generate_lyric(
background_tasks.add_task( background_tasks.add_task(
generate_lyric_background, generate_lyric_background,
task_id=task_id, task_id=task_id,
prompt=selected_lyric_prompt, prompt=lyric_prompt,
lyric_input_data=lyric_input_data, lyric_input_data=lyric_input_data,
lyric_id=lyric.id, lyric_id=lyric.id,
) )
@ -585,29 +570,19 @@ async def get_subtitle_status(
detail=f"task_id '{task_id}'에 해당하는 마케팅 인텔리전스를 찾을 수 없습니다.", detail=f"task_id '{task_id}'에 해당하는 마케팅 인텔리전스를 찾을 수 없습니다.",
) )
# 자막과 이미지 배정 모두 완료돼야 영상 생성 가능 if intel.subtitle:
subtitle_done = bool(intel.subtitle) logger.info(f"[get_subtitle_status] completed - task_id: {task_id}")
image_match_done = bool(intel.image_match)
if subtitle_done and image_match_done:
logger.info(f"[get_subtitle_status] completed (subtitle+image_match) - task_id: {task_id}")
return SubtitleStatusResponse( return SubtitleStatusResponse(
task_id=task_id, task_id=task_id,
status="completed", status="completed",
message="자막 생성 및 이미지 배정이 완료되었습니다.", message="자막 생성이 완료되었습니다.",
) )
pending_parts = [] logger.info(f"[get_subtitle_status] pending - task_id: {task_id}")
if not subtitle_done:
pending_parts.append("자막")
if not image_match_done:
pending_parts.append("이미지 배정")
pending_msg = ", ".join(pending_parts)
logger.info(f"[get_subtitle_status] pending ({pending_msg}) - task_id: {task_id}")
return SubtitleStatusResponse( return SubtitleStatusResponse(
task_id=task_id, task_id=task_id,
status="pending", status="pending",
message=f"{pending_msg} 처리가 진행 중입니다. 잠시 후 다시 확인해주세요.", message="자막 생성이 진행 중입니다. 잠시 후 다시 확인해주세요.",
) )

View File

@ -93,13 +93,6 @@ class Lyric(Base):
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
) )
genre: Mapped[str | None] = mapped_column(
String(20),
nullable=True,
comment="음악 장르 (kpop, pop, ballad, hip-hop, rnb, edm, jazz, rock). "
"수동 선택 시 요청값, 자동 선택 시 GPT 추천값",
)
is_deleted: Mapped[bool] = mapped_column( is_deleted: Mapped[bool] = mapped_column(
Boolean, Boolean,
nullable=False, nullable=False,

View File

@ -70,19 +70,13 @@ class GenerateLyricRequest(BaseModel):
language: str = Field( language: str = Field(
default="Korean", default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
) ),
orientation: Literal["horizontal", "vertical"] = Field( orientation: Literal["horizontal", "vertical"] = Field(
default="vertical", default="vertical",
description="영상 방향 (horizontal: 가로형, vertical: 세로형)", description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
) ),
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값") m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)") instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
genre: Optional[str] = Field(
None,
description="사용자가 수동으로 고른 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). "
"'자동 선택'인 경우 None으로 전달하면 GPT가 가사 무드에 맞춰 추천",
)
class GenerateLyricResponse(BaseModel): class GenerateLyricResponse(BaseModel):
@ -205,10 +199,6 @@ class LyricDetailResponse(BaseModel):
project_id: int = Field(..., description="프로젝트 ID") project_id: int = Field(..., description="프로젝트 ID")
status: str = Field(..., description="처리 상태 (processing, completed, failed)") status: str = Field(..., description="처리 상태 (processing, completed, failed)")
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)") lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
genre: Optional[str] = Field(
None,
description="확정된 음악 장르. 수동 선택 시 요청값, 자동 선택 시 GPT 추천값 (완료 전에는 None)",
)
created_at: Optional[datetime] = Field(None, description="생성 일시") created_at: Optional[datetime] = Field(None, description="생성 일시")

View File

@ -4,24 +4,30 @@ Lyric Background Tasks
가사 생성 관련 백그라운드 태스크를 정의합니다. 가사 생성 관련 백그라운드 태스크를 정의합니다.
""" """
import traceback
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.home.models import Image, Project, MarketingIntel
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.subtitles import SubtitleContentsGenerator
from app.utils.creatomate import CreatomateService
from app.utils.prompts.prompts import Prompt from app.utils.prompts.prompts import Prompt
from app.utils.logger import get_logger from app.utils.logger import get_logger
# 로거 설정 # 로거 설정
logger = get_logger("lyric") logger = get_logger("lyric")
async def _update_lyric_status( async def _update_lyric_status(
task_id: str, task_id: str,
status: str, status: str,
result: str | None = None, result: str | None = None,
lyric_id: int | None = None, lyric_id: int | None = None,
genre: str | None = None,
) -> bool: ) -> bool:
"""Lyric 테이블의 상태를 업데이트합니다. """Lyric 테이블의 상태를 업데이트합니다.
@ -30,7 +36,6 @@ async def _update_lyric_status(
status: 변경할 상태 ("processing", "completed", "failed") status: 변경할 상태 ("processing", "completed", "failed")
result: 가사 결과 또는 에러 메시지 result: 가사 결과 또는 에러 메시지
lyric_id: 특정 Lyric 레코드 ID (재생성 정확한 레코드 식별용) lyric_id: 특정 Lyric 레코드 ID (재생성 정확한 레코드 식별용)
genre: 확정된 음악 장르 (자동 선택이었던 경우 GPT 추천값으로 갱신)
Returns: Returns:
bool: 업데이트 성공 여부 bool: 업데이트 성공 여부
@ -56,8 +61,6 @@ async def _update_lyric_status(
lyric.status = status lyric.status = status
if result is not None: if result is not None:
lyric.lyric_result = result lyric.lyric_result = result
if genre is not None:
lyric.genre = genre
await session.commit() await session.commit()
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}") logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
return True return True
@ -114,18 +117,14 @@ async def generate_lyric_background(
#result = await service.generate(prompt=prompt) #result = await service.generate(prompt=prompt)
result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data) result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data)
result = result_response.lyric result = result_response.lyric
confirmed_genre = result_response.recommended_genre
step2_elapsed = (time.perf_counter() - step2_start) * 1000 step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info( logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자, "
f"genre: {confirmed_genre} ({step2_elapsed:.1f}ms)"
)
# ========== Step 3: DB 상태 업데이트 ========== # ========== Step 3: DB 상태 업데이트 ==========
step3_start = time.perf_counter() step3_start = time.perf_counter()
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...") logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
await _update_lyric_status(task_id, "completed", result, lyric_id, genre=confirmed_genre) await _update_lyric_status(task_id, "completed", result, lyric_id)
step3_elapsed = (time.perf_counter() - step3_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)") logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
@ -156,3 +155,72 @@ async def generate_lyric_background(
elapsed = (time.perf_counter() - task_start) * 1000 elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True) logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id) await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
async def generate_subtitle_background(
orientation: str,
task_id: str,
max_retries: int = 3,
) -> None:
logger.info(f"[generate_subtitle_background] START - task_id: {task_id}, orientation: {orientation}")
for attempt in range(1, max_retries + 1):
try:
creatomate_service = CreatomateService(orientation=orientation)
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
pitchings = creatomate_service.extract_text_format_from_template(template)
subtitle_generator = SubtitleContentsGenerator()
async with BackgroundSessionLocal() as session:
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
store_address = project.detail_region_info
customer_name = project.store_name
language = project.language or "Korean"
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, store_address: {store_address}, language: {language}")
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
marketing_intelligence=marketing_intelligence.intel_result,
pitching_label_list=pitchings,
customer_name=customer_name,
detail_region_info=store_address,
language=language,
)
pitching_output_list = generated_subtitles.pitching_results
subtitle_modifications = {
pitching_output.pitching_tag: pitching_output.pitching_data
for pitching_output in pitching_output_list
}
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
async with BackgroundSessionLocal() as session:
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
marketing_intelligence.subtitle = subtitle_modifications
await session.commit()
logger.info(f"[generate_subtitle_background] DONE - task_id: {task_id} (attempt {attempt}/{max_retries})")
return
except Exception as e:
logger.error(
f"[generate_subtitle_background] FAILED (attempt {attempt}/{max_retries}) - task_id: {task_id}, error: {e}",
exc_info=True,
)
if attempt < max_retries:
logger.info(f"[generate_subtitle_background] 재시도 중... ({attempt + 1}/{max_retries}) - task_id: {task_id}")
logger.error(f"[generate_subtitle_background] 모든 재시도 실패 - task_id: {task_id}")

View File

@ -94,10 +94,8 @@ class SeoService:
), ),
"language": project.language, "language": project.language,
"target_keywords": hashtags, "target_keywords": hashtags,
"industry": project.industry or "", # 크롤 시 분류해 Project에 저장한 업종 enum
} }
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
chatgpt = ChatgptService(timeout=180) chatgpt = ChatgptService(timeout=180)
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data) yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)

View File

@ -144,7 +144,7 @@ class YouTubeUploader(BaseSocialUploader):
body = { body = {
"snippet": { "snippet": {
"title": metadata.title, "title": metadata.title,
"description": self._sanitize_description(metadata.description), "description": metadata.description or "",
"tags": metadata.tags or [], "tags": metadata.tags or [],
"categoryId": self._get_category_id(metadata), "categoryId": self._get_category_id(metadata),
}, },
@ -380,12 +380,6 @@ class YouTubeUploader(BaseSocialUploader):
) )
return False return False
def _sanitize_description(self, description: str | None) -> str:
"""YouTube API가 거부하는 문자를 제거합니다. (<, > 포함 시 invalidVideoDescription 오류 발생)"""
if not description:
return ""
return description.replace("<", "").replace(">", "")
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str: def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
""" """
PrivacyStatus를 YouTube API 형식으로 변환 PrivacyStatus를 YouTube API 형식으로 변환

View File

@ -35,33 +35,6 @@ logger = get_logger("song")
router = APIRouter(prefix="/song", tags=["Song"]) router = APIRouter(prefix="/song", tags=["Song"])
TARGET_SONG_DURATION_SECONDS = 40.0
def _select_clip_by_duration(
clips_data: list[dict], target_seconds: float = TARGET_SONG_DURATION_SECONDS
) -> dict:
"""생성된 클립 중 목표 길이(target_seconds)에 가장 가까운 클립을 선택합니다.
길이 차이가 동일하면 먼저 등장한 클립( 낮은 인덱스) 선택합니다.
duration 정보가 없는 클립은 비교 대상에서 제외되며, 모든 클립에 duration이 없으면
번째 클립을 반환합니다.
"""
best_clip = None
best_diff = None
for clip in clips_data:
duration = clip.get("duration")
if duration is None:
continue
diff = abs(duration - target_seconds)
if best_diff is None or diff < best_diff:
best_diff = diff
best_clip = clip
return best_clip if best_clip is not None else clips_data[0]
@router.post( @router.post(
"/generate/{task_id}", "/generate/{task_id}",
@ -99,7 +72,7 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
``` ```
## 참고 ## 참고
- 생성되는 노래는 40 내외 길이입니다. - 생성되는 노래는 1 이내 길이입니다.
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 있습니다. - song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 있습니다.
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다. - Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
""", """,
@ -440,14 +413,12 @@ async def get_song_status(
clips_data = response_data.get("sunoData") or [] clips_data = response_data.get("sunoData") or []
if clips_data: if clips_data:
# 생성된 클립(보통 2개) 중 목표 길이(1분)에 가장 가까운 클립 선택 (동일하면 첫 번째) # 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
first_clip = _select_clip_by_duration(clips_data) first_clip = clips_data[0]
audio_url = first_clip.get("audioUrl") audio_url = first_clip.get("audioUrl")
clip_duration = first_clip.get("duration") clip_duration = first_clip.get("duration")
logger.debug( logger.debug(
f"[get_song_status] Selected clip by duration - id: {first_clip.get('id')}, " f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}"
f"audio_url: {audio_url}, duration: {clip_duration}, "
f"candidates: {[(c.get('id'), c.get('duration')) for c in clips_data]}"
) )
if audio_url: if audio_url:

View File

@ -1,15 +1,3 @@
# 특별시/광역시/세종시: 하위 행정구역이 구(gu)이므로 별도 처리
METRO_SIDOS: dict[str, str] = {
"서울특별시": "서울시",
"부산광역시": "부산시",
"대구광역시": "대구시",
"인천광역시": "인천시",
"광주광역시": "광주시",
"대전광역시": "대전시",
"울산광역시": "울산시",
"세종특별시": "세종시",
}
SIDO_CITIES: dict[str, list[str]] = { SIDO_CITIES: dict[str, list[str]] = {
"서울특별시": ["서울시"], "서울특별시": ["서울시"],
"부산광역시": ["부산시"], "부산광역시": ["부산시"],
@ -73,12 +61,6 @@ def extract_sigungu(address: str) -> str:
# 첫 토큰으로 도 판별 (정식명 or 약칭) # 첫 토큰으로 도 판별 (정식명 or 약칭)
sido = SIDO_NAME_ALIASES.get(tokens[0], tokens[0]) sido = SIDO_NAME_ALIASES.get(tokens[0], tokens[0])
# 특별시/광역시/세종시: 구(district) 레벨이 하위이므로 시 이름을 바로 반환
metro_name = METRO_SIDOS.get(sido)
if metro_name:
return metro_name
cities = SIDO_CITIES.get(sido) cities = SIDO_CITIES.get(sido)
if cities and len(tokens) >= 2: if cities and len(tokens) >= 2:
second = tokens[1] second = tokens[1]

View File

@ -6,11 +6,10 @@ from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommen
import asyncio import asyncio
async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_list async def autotag_image(image_url : str) -> list[str]: #tag_list
chatgpt = ChatgptService(model_type="gpt") chatgpt = ChatgptService(model_type="gemini")
image_input_data = { image_input_data = {
"img_url" : image_url, "img_url" : image_url,
"industry" : industry,
"space_type" : list(SpaceType), "space_type" : list(SpaceType),
"subject" : list(Subject), "subject" : list(Subject),
"camera" : list(Camera), "camera" : list(Camera),
@ -20,11 +19,10 @@ async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False) image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False)
return image_result return image_result
async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_list
chatgpt = ChatgptService(model_type="gpt") chatgpt = ChatgptService(model_type="gemini")
image_input_data_list = [{ image_input_data_list = [{
"img_url" : image_url, "img_url" : image_url,
"industry" : industry,
"space_type" : list(SpaceType), "space_type" : list(SpaceType),
"subject" : list(Subject), "subject" : list(Subject),
"camera" : list(Camera), "camera" : list(Camera),
@ -33,10 +31,10 @@ async def autotag_images(image_url_list : list[str], industry: str = "") -> list
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list] image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True) image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
MAX_RETRY = 2 MAX_RETRY = 2 # 하드코딩, 어떻게 처리할지는 나중에
for _ in range(MAX_RETRY): for _ in range(MAX_RETRY):
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)] failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
# print("Failed", failed_idx) print("Failed", failed_idx)
if not failed_idx: if not failed_idx:
break break
retried = await asyncio.gather( retried = await asyncio.gather(
@ -46,5 +44,5 @@ async def autotag_images(image_url_list : list[str], industry: str = "") -> list
for i, result in zip(failed_idx, retried): for i, result in zip(failed_idx, retried):
image_result_list[i] = result image_result_list[i] = result
# print("Failed", failed_idx) print("Failed", failed_idx)
return image_result_list return image_result_list

View File

@ -30,7 +30,6 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
""" """
import copy import copy
import random
import time import time
from enum import StrEnum from enum import StrEnum
from typing import Literal from typing import Literal
@ -225,120 +224,28 @@ autotext_template_h_1 = {
"stroke_color": "#333333", "stroke_color": "#333333",
"stroke_width": "0.2 vmin" "stroke_width": "0.2 vmin"
} }
DVST0001 = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6" DVST0001 = "75161273-0422-4771-adeb-816bd7263fb0"
DVRT0001 = "3c4b74c4-82d7-4742-9b05-4b999fef4cbd" DVST0002 = "c68cf750-bc40-485a-a2c5-3f9fe301e386"
DVCF0001 = "ebb532ec-e49a-4a7e-b0e2-a8132f1bf1f7" DVST0003 = "e1fb5b00-1f02-4f63-99fa-7524b433ba47"
DVAT0001 = "14d4fa22-49b8-49ca-a233-ac0556436f60"
DVSL0001 = "20c526a7-b184-46f3-9138-9dfaff2fa342"
DVCL0001 = "b57dca80-167d-430a-8905-30f4674ff72b"
DVFT0001 = "03b6d521-4864-4e69-8e6c-1deab9f0ce6e"
DVAC0001 = "4db8f5a3-4fd1-42cf-86b3-d997f0713d99"
DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98" DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98"
DHST0002 = "3f194cc7-464e-4581-9db2-179d42d3e40f"
HST_LIST = [DHST0001] DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d"
VST_LIST = [DVST0001,DVRT0001,DVCF0001,DVAT0001,DVSL0001,DVCL0001,DVFT0001,DVAC0001] DVST0001T = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
HST_LIST = [DHST0001,DHST0002,DHST0003]
# industry → 세로형 템플릿 매핑 (신규 세로형 템플릿 추가 시 여기에만 등록) VST_LIST = [DVST0001,DVST0002,DVST0003, DVST0001T]
VERTICAL_INDUSTRY_TEMPLATE_MAP: dict[str, str] = {
"stay": DVST0001,
"restaurant": DVRT0001,
"cafe": DVCF0001,
"attraction": DVAT0001,
"salon": DVSL0001,
"clinic": DVCL0001,
"fitness": DVFT0001,
"academy": DVAC0001,
}
# 매핑에 없는 업종(general)의 폴백 템플릿
DEFAULT_VERTICAL_TEMPLATE = DVST0001
# 템플릿 슬롯명의 모션 동의어 → MotionRecommended 표준값 정규화 맵.
# 동의어를 enum에 추가하면 LLM 태그 후보가 갈라져 매칭 정확도가 떨어지므로,
# enum은 표준 어휘만 유지하고 슬롯명 파싱 경계에서 흡수한다.
MOTION_TOKEN_NORMALIZATION: dict[str, MotionRecommended] = {
"zoom_in": MotionRecommended.slow_zoom_in,
"zoom_out": MotionRecommended.slow_zoom_out,
"pan_left": MotionRecommended.slow_pan,
"pan_right": MotionRecommended.slow_pan,
"tilt_up": MotionRecommended.slow_pan,
"tilt_down": MotionRecommended.slow_pan,
"push_diag": MotionRecommended.dolly,
"montage": MotionRecommended.static,
}
SCENE_TRACK = 1 SCENE_TRACK = 1
AUDIO_TRACK = 2 AUDIO_TRACK = 2
SUBTITLE_TRACK = 3 SUBTITLE_TRACK = 3
KEYWORD_TRACK = 4 KEYWORD_TRACK = 4
# 썸네일 컴포지션의 이미지 슬롯명 접미사 (8개 업종 템플릿 전수 확인, 예: def select_template(orientation:OrientationType):
# "exterior_front-architecture_detail-wide_angle-slow_zoom_in-intro-core-9999").
# 일반 씬 이미지는 "-0001"~"-0030"류 4자리 순번을 쓰는 반면 썸네일만 "-9999"라
# 슬롯명 접미사만으로 안전하게 식별 가능(가로 템플릿처럼 썸네일 컴포지션이
# 없는 템플릿도 있음).
THUMBNAIL_SLOT_MARKER = "-9999"
def is_fixed_slot_name(name: str) -> bool:
"""이름이 '-fixed'로 끝나는 요소인지 판별합니다.
템플릿 명명 규칙상 마지막 토큰이 'fixed' 요소(: 'brand-cta-bi_logo-
factual-fixed', 'brand-cta-contact_phone-factual-fixed')는 회사 연락처
문구·로고 영상마다 절대 바뀌면 되는 고정 콘텐츠다. 이런 요소는
이미지 배정/자막 생성 대상에서 제외하고 템플릿에 이미 설정된 값을 그대로
보존해야 한다.
"""
if not name:
return False
return name.rsplit("-", 1)[-1] == "fixed"
# 언어별 대체 폰트 (Google Fonts — Creatomate 기본 지원).
# 템플릿 기본 폰트(GyeonggiTitleOTF/Pretendard)는 한글·라틴 전용이라 여기 있는
# 언어는 렌더 직전 전체 텍스트 폰트를 교체한다. 매핑에 없는 언어(Korean,
# English 등)는 템플릿 원본 폰트를 유지한다. 중국어는 간체(SC) 기준.
LANGUAGE_FONT_MAP = {
"Japanese": "Noto Sans JP",
"Chinese": "Noto Sans SC",
"Thai": "Noto Sans Thai",
"Vietnamese": "Noto Sans",
}
def select_template(
orientation: OrientationType,
industry: str | None = None,
project_id: int | None = None,
) -> str:
"""orientation과 industry에 따라 Creatomate 템플릿 ID를 선택합니다.
Args:
orientation: 영상 방향 ("horizontal" 또는 "vertical")
industry: 업종 분류 (stay|restaurant|cafe|attraction ).
세로형에서 매핑에 있으면 전용 템플릿을 반환합니다.
project_id: 미매핑 업종(general ) 세로형 분배용 .
project_id % len(VST_LIST) 결정적으로 선택하므로, 같은
프로젝트는 호출해도 동일 템플릿을 받아 사전/렌더 단계가
일치한다. 없으면 DEFAULT_VERTICAL_TEMPLATE로 폴백한다.
Returns:
선택된 템플릿 ID
"""
if orientation == "horizontal": if orientation == "horizontal":
template_id = DHST0001 return DHST0001
elif orientation == "vertical": elif orientation == "vertical":
mapped = VERTICAL_INDUSTRY_TEMPLATE_MAP.get(industry) return DVST0001T
if mapped is not None:
template_id = mapped
elif project_id is not None:
# 미매핑 업종: project_id 나머지로 VST_LIST 결정적 분배 (공유 상태 없음)
template_id = VST_LIST[project_id % len(VST_LIST)]
else:
template_id = DEFAULT_VERTICAL_TEMPLATE
else: else:
raise raise
logger.info(f"[select_template] orientation={orientation}, industry={industry}, project_id={project_id}, template_id={template_id}")
return template_id
async def get_shared_client() -> httpx.AsyncClient: async def get_shared_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
@ -386,25 +293,21 @@ class CreatomateService:
def __init__( def __init__(
self, self,
api_key: str | None = None, api_key: str | None = None,
orientation: OrientationType = "vertical", orientation: OrientationType = "vertical"
industry: str | None = None,
project_id: int | None = None,
): ):
""" """
Args: Args:
api_key: Creatomate API (Bearer token으로 사용) api_key: Creatomate API (Bearer token으로 사용)
None일 경우 config에서 자동으로 가져옴 None일 경우 config에서 자동으로 가져옴
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical") orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
industry: 업종 분류 (stay|restaurant|cafe|attraction ). 세로형 템플릿 선택에 사용되며, target_duration: 목표 영상 길이 ()
매핑에 있으면 전용 템플릿을 사용 None일 경우 orientation에 해당하는 기본값 사용
project_id: 미매핑 업종(general ) 세로형 분배용 . 매핑에 없을
project_id % len(VST_LIST) 결정적 선택
""" """
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
self.orientation = orientation self.orientation = orientation
# orientation·industry에 따른 템플릿 설정 가져오기 # orientation에 따른 템플릿 설정 가져오기
self.template_id = select_template(orientation, industry=industry, project_id=project_id) self.template_id = select_template(orientation)
self.headers = { self.headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
@ -540,309 +443,52 @@ class CreatomateService:
template : dict, template : dict,
target_template_type : str target_template_type : str
) -> list: ) -> list:
"""target_template_type 요소 개수를 셉니다.
target_template_type이 "image" 경우, 고정 자산('-fixed' 명명)
5토큰 명명 규칙을 따르지 않는 요소(고정 워터마크 ) 콘텐츠 슬롯이
아니므로 카운트에서 제외합니다 (template_matching_taged_image의
image_slots 필터링과 동일 기준).
"""
source_elements = template["source"]["elements"] source_elements = template["source"]["elements"]
template_component_data = self.parse_template_component_name(source_elements) template_component_data = self.parse_template_component_name(source_elements)
count = 0 count = 0
for name, template_type in template_component_data.items(): for _, (_, template_type) in enumerate(template_component_data.items()):
if template_type != target_template_type: if template_type == target_template_type:
continue
if target_template_type == "image" and (is_fixed_slot_name(name) or self.parse_slot_name_to_tag(name) is None):
continue
count += 1 count += 1
return count return count
def _slot_scores_with_fitness(
self,
pool_subset: list[dict],
slot: str,
thumbnail_fitness_map: dict | None,
) -> list[float]:
"""슬롯 점수에 썸네일 픽셀 적합도를 결합합니다.
썸네일 슬롯(-9999) 한해 태그 점수에 적합도를 반영한다:
- reject(저해상도/극단 가로형) 0 (하드 배제)
- 정상 태그 점수 × 적합도 배율(0.25~1.0)
- 적합도 데이터 없음(헤더 확보/파싱 실패) 태그 점수 그대로(중립).
다운로드 실패를 배제로 오판해 전체가 날아가는 것을 막기 위함.
일반 슬롯은 태그 점수를 그대로 반환한다.
"""
scores = self.calculate_image_slot_score_multi(pool_subset, slot)
if not thumbnail_fitness_map or not slot.endswith(THUMBNAIL_SLOT_MARKER):
return scores
adjusted = []
for item, score in zip(pool_subset, scores):
fitness = thumbnail_fitness_map.get(item.get("image_url"))
if fitness is None:
adjusted.append(score)
elif fitness["reject"]:
adjusted.append(0.0)
else:
adjusted.append(score * fitness["score"])
return adjusted
def rank_thumbnail_candidates(
self,
template: dict,
taged_image_list: list,
thumbnail_fitness_map: dict | None = None,
top_n: int = 3,
) -> dict[str, list[dict]]:
"""썸네일 슬롯별로 상위 후보(태그×픽셀 점수 내림차순)를 반환합니다.
비전 LLM 최종 선택(Phase 2) 넘길 후보 압축용. 읽기 전용 pool을
변형하지 않으며 배정도 하지 않는다. 점수 0 이하(픽셀 reject 포함)
제외한다.
Returns:
{슬롯명: [pool item, ...]} 슬롯당 최대 top_n개, 점수 내림차순.
후보가 없는 슬롯은 자체를 포함하지 않는다.
"""
component = self.parse_template_component_name(template["source"]["elements"])
thumbnail_slots = [
name for name, t in component.items()
if t == "image" and name.endswith(THUMBNAIL_SLOT_MARKER)
and not is_fixed_slot_name(name) and self.parse_slot_name_to_tag(name) is not None
]
result: dict[str, list[dict]] = {}
for slot in thumbnail_slots:
scores = self._slot_scores_with_fitness(taged_image_list, slot, thumbnail_fitness_map)
ranked = sorted(
range(len(scores)), key=lambda i: scores[i], reverse=True
)
candidates = [taged_image_list[i] for i in ranked if scores[i] > 0][:top_n]
if candidates:
result[slot] = candidates
return result
def template_matching_taged_image( def template_matching_taged_image(
self, self,
template: dict, template : dict,
taged_image_list: list, # [{"image_url": str, "image_tag": dict}] — 이미 마케팅 적합성 필터를 통과한 이미지만 전달할 것 taged_image_list : list, # [{"image_name" : str , "image_tag" : dict}]
music_url: str, music_url: str,
address: str, address : str,
duplicate: bool = False, duplicate : bool = False
thumbnail_fitness_map: dict | None = None, # {image_url: {"score", "reject"}} — 썸네일 슬롯 픽셀 적합도 ) -> list:
thumbnail_choice: dict | None = None, # {슬롯명: image_url} — 비전 LLM 최종 선택(있으면 결정론 최고점 대신 사용)
) -> tuple[dict, dict]:
"""템플릿 슬롯에 이미지를 배정합니다.
Returns:
(modifications, assigned) 튜플
- modifications: {슬롯명: image_url, "audio-music": music_url, ...} modify_element에 전달
- assigned: {슬롯명: image_tag} 자막 생성 컨텍스트에 전달 (modify_element에 넣지 않음)
배정 전략 (duplicate=False):
- 순수 greedy pop 대신 "난이도 우선 greedy" 적용
- 사전에 전체 슬롯 점수를 계산해 최고 달성 점수가 낮은(=까다로운) 슬롯을 먼저 배정
- 이미지 배정 직전 점수를 재계산 (pop 이후 인덱스 변동 대응)
배정 전략 (duplicate=True, 이미지 < 슬롯):
- 1단계(최소 커버리지 보장): 이미지별로 가장 맞는 슬롯을 찾아 pool의 모든 이미지를
서로 다른 슬롯에 먼저 배정한다 (이미지 <= 슬롯 수이므로 전량 배정 가능).
슬롯을 먼저 소진시키는 것이 아니라 "이미지" 기준으로 난이도 우선 배정하여,
특정 이미지 장이 여러 슬롯을 독점하는 것을 방지한다.
- 2단계: 1단계에서 채운 나머지 슬롯은 기존처럼 pool 전체에서 최고점 이미지를
배정한다 (여기서부터는 중복 재사용 불가피).
Note:
marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되므로 여기서는 재필터링하지 않는다.
taged_image_list를 그대로 pool로 사용한다.
"""
source_elements = template["source"]["elements"] source_elements = template["source"]["elements"]
template_component_data = self.parse_template_component_name(source_elements) template_component_data = self.parse_template_component_name(source_elements)
pool = taged_image_list taged_image_list = [img for img in taged_image_list if img.get("image_tag") is not None]
modifications: dict = {} modifications = {}
assigned: dict = {} # {슬롯명: image_tag} — 자막 컨텍스트용
# 이미지 슬롯과 텍스트 슬롯 분리 for slot_idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
# 고정 자산(이름이 '-fixed'로 끝나는 로고 등) 및 5토큰 명명 규칙을 따르지 match template_type:
# 않는 image 요소는 콘텐츠 슬롯이 아니므로 배정 대상에서 제외 — 그렇지 case "image":
# 않으면 파싱 실패로 0점 처리되어 "가장 까다로운 슬롯"으로 취급되고 image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name)
# 무작위 이미지로 덮어써진다. maximum_idx = image_score_list.index(max(image_score_list))
image_slots = [ if duplicate:
name for name, t in template_component_data.items() selected = taged_image_list[maximum_idx]
if t == "image" and not is_fixed_slot_name(name) and self.parse_slot_name_to_tag(name) is not None
]
text_slots = [(name, t) for name, t in template_component_data.items() if t == "text"]
# ── 썸네일 슬롯(-9999) 우선 배정 ─────────────────────────────────
# 썸네일은 영상에서 가장 노출이 큰 표면이므로 씬 슬롯과 다르게 취급한다:
# 1) 씬 슬롯이 pool에서 좋은 컷을 pop해 가기 전에 먼저 배정 (같은 태그
# 계열 씬 슬롯이 여러 개면 썸네일 차례에 적합 컷이 소진되는 문제 방지)
# 2) "상위 3장 가중 랜덤" 없이 결정론적 최고점 선택 — 랜덤 다양화가
# 저점수 컷(예: space_type 불일치 ×0.1 페널티 컷)을 확률적으로
# 썸네일에 앉히는 사고 방지
# duplicate(이미지 부족) 시에는 pop하지 않아 씬 커버리지를 깎지 않는다.
# thumbnail_choice(비전 LLM 최종 선택)가 해당 슬롯에 있으면 그 이미지를,
# 없으면(선택 실패/미제공) 결정론적 최고점 컷을 사용한다 — 폴백 안전.
thumbnail_choice = thumbnail_choice or {}
thumbnail_slots = [s for s in image_slots if s.endswith(THUMBNAIL_SLOT_MARKER)]
image_slots = [s for s in image_slots if not s.endswith(THUMBNAIL_SLOT_MARKER)]
for slot in thumbnail_slots:
if not pool:
logger.warning(f"[template_matching_taged_image] 이미지 풀 없음 — 썸네일 슬롯 배정 불가: {slot}")
break
scores = self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map)
chosen_url = thumbnail_choice.get(slot)
chosen_idx = next(
(i for i, item in enumerate(pool) if item["image_url"] == chosen_url), None
) if chosen_url else None
if chosen_idx is not None:
sel_idx, sel_source = chosen_idx, "vision"
else: else:
sel_idx, sel_source = max(range(len(scores)), key=lambda i: scores[i]), "결정론" selected = taged_image_list.pop(maximum_idx)
selected = pool[sel_idx] if duplicate else pool.pop(sel_idx) image_name = selected["image_url"]
modifications[slot] = selected["image_url"] modifications[template_component_name] =image_name
assigned[slot] = selected["image_tag"] pass
logger.info( case "text":
f"[template_matching_taged_image] 썸네일 배정({sel_source}) — slot: {slot}, " if "address_input" in template_component_name:
f"score: {scores[sel_idx]:.3f}, url: {selected['image_url']}" modifications[template_component_name] = address
)
if not pool:
logger.warning("[template_matching_taged_image] 태그된 이미지가 없음 — 이미지 슬롯 배정 불가")
elif duplicate:
# 이미지 부족(슬롯 수 > 이미지 수)
# Step 1: 최소 커버리지 보장 — 이미지마다 가장 잘 맞는 슬롯을 찾아 서로 다른 슬롯에 배정
remaining_slots = list(image_slots)
def _best_slot_for_image(image: dict, slots: list) -> tuple[str | None, float]:
best_slot, best_score = None, -1.0
for slot in slots:
score = self._slot_scores_with_fitness([image], slot, thumbnail_fitness_map)[0]
if score > best_score:
best_slot, best_score = slot, score
return best_slot, best_score
# 사전 점수 계산 (최고 달성 점수가 낮을수록 배정처가 마땅찮은=까다로운 이미지)
prelim_best_score = [
_best_slot_for_image(image, remaining_slots)[1] for image in pool
]
ordered_indices = sorted(
range(len(pool)),
key=lambda i: prelim_best_score[i]
)
for idx in ordered_indices:
if not remaining_slots:
break
image = pool[idx]
# 슬롯이 이전 배정으로 줄어들었으므로 현재 remaining_slots 기준 재계산
best_slot, _ = _best_slot_for_image(image, remaining_slots)
if best_slot is None:
continue
modifications[best_slot] = image["image_url"]
assigned[best_slot] = image["image_tag"]
remaining_slots.remove(best_slot)
# Step 2: 커버리지 배정 후 남은 슬롯은 pool 전체에서 최고점 이미지 배정 (재사용 불가피)
for slot in remaining_slots:
scores = self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map)
if not scores:
continue
best_idx = scores.index(max(scores))
selected = pool[best_idx]
modifications[slot] = selected["image_url"]
assigned[slot] = selected["image_tag"]
else:
# 이미지 충분(슬롯 수 <= 이미지 수): 난이도 우선 greedy 배정
# Step 1: 정렬용 사전 점수 계산 (최고 달성 점수가 낮을수록 배정이 어려운 슬롯)
prelim = {slot: self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map) for slot in image_slots}
ordered_slots = sorted(
image_slots,
key=lambda s: max(prelim[s]) if prelim[s] else 0.0
# 오름차순 → 달성 최대값이 낮은(어려운) 슬롯이 앞으로
)
# Step 2: 난이도 순서로 배정, 배정마다 pool에서 pop + 다음 슬롯은 점수 재계산
for slot in ordered_slots:
if not pool:
logger.warning(f"[template_matching_taged_image] 이미지 풀 소진 — 남은 슬롯 배정 불가: {slot}")
break
# pop 후 인덱스 변동이 있으므로 현재 pool로 재계산 (사전 점수 재사용 금지)
scores = self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map)
if not scores:
continue
# 상위 3장 중 점수 비례 확률로 선택 (영상 다양화)
sorted_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
top_indices = sorted_indices[:min(3, len(sorted_indices))]
weights = [scores[i] for i in top_indices]
if sum(weights) > 0:
chosen_pool_idx = random.choices(top_indices, weights=weights, k=1)[0]
else:
chosen_pool_idx = random.choice(top_indices)
selected = pool.pop(chosen_pool_idx)
modifications[slot] = selected["image_url"]
assigned[slot] = selected["image_tag"]
# 텍스트 슬롯 처리
for name, _ in text_slots:
if "address_input" in name:
modifications[name] = address
modifications["audio-music"] = music_url modifications["audio-music"] = music_url
return modifications, assigned return modifications
# 점수 계산 상수 (모듈 레벨로 빼면 더 좋지만 기존 코드 스타일 따라 클래스 내부에 위치)
_NARR_FLOOR = 0.25 # narrative 최솟값 modulator: base 점수가 완전히 0이 되는 것을 방지
_NARR_BONUS = 1.5 # narrative 독립 보너스: base=0이어도 narrative 신호가 점수에 남음
_NARR_MIN = 0.0 # narrative_preference 값 clamp 하한 (무경계 스키마 방어)
_NARR_MAX = 100.0 # narrative_preference 값 clamp 상한. NarrativePreference 스키마(0.0~100.0)와 스케일 일치
_SPACE_TYPE_MISMATCH_PENALTY = 0.1 # 슬롯이 요구하는 space_type과 다른 이미지에 적용하는 페널티 배수 (하드 필터에 가깝게)
def calculate_image_slot_score_multi(self, taged_image_list : list[dict], slot_name : str): def calculate_image_slot_score_multi(self, taged_image_list : list[dict], slot_name : str):
"""이미지 슬롯에 대한 각 이미지의 매칭 점수를 계산합니다.
점수 = (base * narr_mod + NARR_BONUS * narr) * space_type_penalty
- base: 태그 카테고리 매칭 합산 (최대 6.5 = space_type 2.0 + subject 3.0 + camera 1.0 + motion 0.5)
- narr_mod: [NARR_FLOOR, 1.0] 구간으로 완화된 narrative 계수
- NARR_BONUS * narr: narrative 독립 보너스 (base=0이어도 신호 유지)
- space_type_penalty: 슬롯이 요구하는 space_type과 이미지가 불일치하면
SPACE_TYPE_MISMATCH_PENALTY(0.1) 곱해 사실상 탈락시킴. narrative_preference
점수가 아무리 높아도(: bathroom 이미지가 intro narr=100) 슬롯이 지정한
space_type(: exterior_front) 다르면 우선 배정되지 않도록 하기 위함
같은 space_type 이미지가 pool에 하나도 없을 때만 상대적으로 선택됨(fallback 유지).
구조로 "base=0, narrative가 높음" "완전히 틀린 이미지(0,0)" 구별할 있음.
"""
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list] image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
slot_tag_dict = self.parse_slot_name_to_tag(slot_name) slot_tag_dict = self.parse_slot_name_to_tag(slot_name)
if slot_tag_dict is None: image_score_list = [0] * len(image_tag_list)
# parse 실패 시 전부 0점 반환 (슬롯 skip)
return [0.0] * len(image_tag_list)
base_score_list = [0.0] * len(image_tag_list)
# slot_tag_narrative = NarrativePhase.accent # 기본값 (narrative 토큰 없는 슬롯 대비)
slot_space_type = slot_tag_dict.get("space_type")
space_type_match_list = [
slot_space_type is not None and slot_space_type.value in image_tag.get("space_type", [])
for image_tag in image_tag_list
]
# 썸네일 슬롯 한정: subject가 슬롯 요구와 일치하면 space_type 불일치
# 페널티를 면제한다. 배경: 일부 업종(예: 레스토랑 dining_hall-food_dish)은
# 태깅 관행상 슬롯이 요구하는 subject(음식 클로즈업)가 실제로는 다른
# space_type(table_setting/detail_plating 등)으로 붙는다 — space_type과
# subject가 한 사진에 공존하기 어려운 조합. 이런 슬롯은 space_type 하드
# 필터가 정작 원하는 subject 이미지를 전부 걸러내는 역효과를 낸다.
# 템플릿 슬롯명을 바꾸지 않고 여기서 흡수(썸네일 한정이라 일반 씬 슬롯의
# space_type 하드 필터는 그대로 유지).
slot_subject = slot_tag_dict.get("subject")
if slot_name.endswith(THUMBNAIL_SLOT_MARKER) and slot_subject is not None:
for idx, image_tag in enumerate(image_tag_list):
if slot_subject.value in image_tag.get("subject", []):
space_type_match_list[idx] = True
for slot_tag_cate, slot_tag_item in slot_tag_dict.items(): for slot_tag_cate, slot_tag_item in slot_tag_dict.items():
if slot_tag_cate == "narrative_preference": if slot_tag_cate == "narrative_preference":
@ -851,64 +497,41 @@ class CreatomateService:
match slot_tag_cate: match slot_tag_cate:
case "space_type": case "space_type":
weight = 2.0 weight = 2
case "subject": case "subject" :
weight = 3.0 weight = 2
case "camera": case "camera":
weight = 1.0 weight = 1
case "motion_recommended": case "motion_recommended" :
weight = 0.5 weight = 0.5
case _: case _:
raise ValueError(f"[calculate_image_slot_score_multi] unknown slot tag category: {slot_tag_cate}") raise
for idx, image_tag in enumerate(image_tag_list): for idx, image_tag in enumerate(image_tag_list):
if slot_tag_item.value in image_tag[slot_tag_cate]: # collect! if slot_tag_item.value in image_tag[slot_tag_cate]: #collect!
base_score_list[idx] += weight image_score_list[idx] += weight
# narrative 점수 적용: 순수 곱셈 대신 "완화 modulator + 독립 보너스"
# - narr_mod: base를 흐릴 수 있지만 NARR_FLOOR 이하로는 안 떨어짐
# - NARR_BONUS * narr: base=0이어도 narrative가 높으면 양수 점수 확보
image_score_list = []
for idx, image_tag in enumerate(image_tag_list): for idx, image_tag in enumerate(image_tag_list):
raw_narr = image_tag.get("narrative_preference", {}).get(slot_tag_narrative, 0.0) image_narrative_score = image_tag["narrative_preference"][slot_tag_narrative]
clamped_narr = min(self._NARR_MAX, max(self._NARR_MIN, float(raw_narr))) image_score_list[idx] = image_score_list[idx] * image_narrative_score
narr = clamped_narr / self._NARR_MAX # 0.0~1.0로 정규화
narr_mod = self._NARR_FLOOR + (1.0 - self._NARR_FLOOR) * narr
score = base_score_list[idx] * narr_mod + self._NARR_BONUS * narr
if not space_type_match_list[idx]:
score *= self._SPACE_TYPE_MISMATCH_PENALTY
image_score_list.append(score)
return image_score_list return image_score_list
def parse_slot_name_to_tag(self, slot_name: str) -> dict[str, StrEnum] | None: def parse_slot_name_to_tag(self, slot_name : str) -> dict[str, StrEnum]:
"""슬롯 이름을 파싱하여 태그 딕셔너리를 반환합니다.
슬롯 이름 형식: {space_type}-{subject}-{camera}-{motion}-{narrative}
파싱 실패 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리).
"""
try:
tag_list = slot_name.split("-") tag_list = slot_name.split("-")
if len(tag_list) < 5:
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 토큰 부족 ({len(tag_list)}개): '{slot_name}' — 슬롯 skip")
return None
space_type = SpaceType(tag_list[0]) space_type = SpaceType(tag_list[0])
subject = Subject(tag_list[1]) subject = Subject(tag_list[1])
camera = Camera(tag_list[2]) camera = Camera(tag_list[2])
# 모션 동의어(zoom_in, pan_left 등)는 표준 enum 값으로 정규화 후 변환 motion = MotionRecommended(tag_list[3])
motion = MOTION_TOKEN_NORMALIZATION.get(tag_list[3]) or MotionRecommended(tag_list[3])
narrative = NarrativePhase(tag_list[4]) narrative = NarrativePhase(tag_list[4])
tag_dict = { tag_dict = {
"space_type": space_type, "space_type" : space_type,
"subject": subject, "subject" : subject,
"camera": camera, "camera" : camera,
"motion_recommended": motion, "motion_recommended" : motion,
"narrative_preference": narrative, "narrative_preference" : narrative,
} }
return tag_dict return tag_dict
except (ValueError, IndexError) as e:
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 파싱 실패: '{slot_name}'{e} — 슬롯 skip")
return None
def elements_connect_resource_blackbox( def elements_connect_resource_blackbox(
self, self,
@ -939,26 +562,20 @@ class CreatomateService:
return modifications return modifications
def modify_element(self, elements: list, modification: dict) -> list: def modify_element(self, elements: list, modification: dict) -> list:
"""elements의 source를 modification에 따라 수정합니다. """elements의 source를 modification에 따라 수정합니다."""
modification에 없는 image/text 요소(: '-fixed' 명명의 고정 로고·
연락처 문구처럼 배정/자막 생성 대상에서 제외된 고정 콘텐츠) 템플릿에
이미 설정된 source/text를 그대로 보존한다 (KeyError· 문자열로
렌더가 깨지지 않도록).
"""
def recursive_modify(element: dict) -> None: def recursive_modify(element: dict) -> None:
if "name" in element: if "name" in element:
match element["type"]: match element["type"]:
case "image": case "image":
element["source"] = modification.get(element["name"], element.get("source")) element["source"] = modification[element["name"]]
case "audio": case "audio":
element["source"] = modification.get(element["name"], "") element["source"] = modification.get(element["name"], "")
case "video": case "video":
element["source"] = modification[element["name"]] element["source"] = modification[element["name"]]
case "text": case "text":
#element["source"] = modification[element["name"]] #element["source"] = modification[element["name"]]
element["text"] = modification.get(element["name"], element.get("text", "")) element["text"] = modification.get(element["name"], "")
case "composition": case "composition":
for minor in element["elements"]: for minor in element["elements"]:
recursive_modify(minor) recursive_modify(minor)
@ -1062,8 +679,6 @@ class CreatomateService:
""" """
url = f"{self.BASE_URL}/v2/renders" url = f"{self.BASE_URL}/v2/renders"
source["frame_rate"] = 30
last_error: Exception | None = None last_error: Exception | None = None
for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1): for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1):
@ -1140,20 +755,6 @@ class CreatomateService:
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
@staticmethod
def _entrance_transition_offset(elem: dict) -> float:
"""요소의 entrance transition(transition=true, time==0) duration 합을 반환합니다.
순차 배치 값만큼 이전 요소와 겹치도록 시작점이 앞당겨집니다.
"""
offset = 0.0
for animation in elem.get("animations", []):
if "reversed" in animation:
continue
if "transition" in animation and animation["transition"] and animation.get("time", 0) == 0:
offset += animation["duration"]
return offset
def calc_scene_duration(self, template: dict) -> float: def calc_scene_duration(self, template: dict) -> float:
"""템플릿의 전체 장면 duration을 계산합니다.""" """템플릿의 전체 장면 duration을 계산합니다."""
total_template_duration = 0.0 total_template_duration = 0.0
@ -1167,7 +768,16 @@ class CreatomateService:
if elem["track"] not in track_maximum_duration: if elem["track"] not in track_maximum_duration:
continue continue
if "time" not in elem or elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음 if "time" not in elem or elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음
track_maximum_duration[elem["track"]] += elem["duration"] - self._entrance_transition_offset(elem) track_maximum_duration[elem["track"]] += elem["duration"]
if "animations" not in elem:
continue
for animation in elem["animations"]:
if "reversed" in animation:
continue
assert animation.get("time",0) == 0 # 0이 아닌 경우 확인 필요
if "transition" in animation and animation["transition"]:
track_maximum_duration[elem["track"]] -= animation["duration"]
else: else:
track_maximum_duration[elem["track"]] = max(track_maximum_duration[elem["track"]], elem["time"] + elem["duration"]) track_maximum_duration[elem["track"]] = max(track_maximum_duration[elem["track"]], elem["time"] + elem["duration"])
@ -1179,73 +789,6 @@ class CreatomateService:
return total_template_duration return total_template_duration
def calc_track_time_ranges(self, template: dict, track: int) -> list[tuple[str, float, float]]:
"""최상위 요소 중 지정 track의 (name, start, end) 시간 범위를 계산합니다.
Creatomate 규칙에 따라 `time` 없거나 0 요소는 같은 트랙의 이전 요소 뒤에
순차 배치되며, entrance transition duration만큼 이전 요소와 겹치도록
시작점을 앞당깁니다(calc_scene_duration의 차감 규칙과 동치).
`time` 명시된 요소는 해당 시각에서 시작하고, 커서는 요소의 끝으로 이동합니다.
"""
ranges: list[tuple[str, float, float]] = []
cursor = 0.0
for elem in template["source"]["elements"]:
try:
if elem.get("track") != track:
continue
duration = elem["duration"]
if "time" not in elem or elem["time"] == 0: # 순차 배치(auto)
start = max(0.0, cursor - self._entrance_transition_offset(elem))
else:
start = elem["time"]
end = start + duration
cursor = end
ranges.append((elem.get("name", ""), start, end))
except Exception as e:
logger.debug(traceback.format_exc())
logger.error(f"[calc_track_time_ranges] Error processing element: {elem}, {e}")
return ranges
@staticmethod
def _scale_element(elem: dict, extend_rate: float) -> None:
"""element 하나의 time/duration/animations를 extend_rate만큼 스케일링합니다.
composition의 자식 element까지 재귀적으로 처리합니다. 재귀하지 않으면
부모 컴포지션( 슬롯) 늘어나고 내부 leaf 이미지/애니메이션은 원래
길이에서 끝나버려, 슬롯이 끝나기 컴포지션의 fill_color(회색)
노출되는 버그가 생긴다 modify_element/parse_template_component_name과
동일하게 composition을 재귀 순회해야 .
오디오 판별은 track 번호가 아닌 type == "audio" 한다. composition
내부의 track 번호는 컴포지션 안에서만 유효한 로컬 레이어 인덱스라
전역 AUDIO_TRACK/KEYWORD_TRACK 상수와 우연히 값이 겹칠 있다
(실제 템플릿에도 다중 레이어 씬의 2번째/3번째 이미지가 track=2, track=4
경우가 존재함).
"""
if elem.get("type") == "audio":
return
# "time"은 숫자 오프셋 대신 "end"(컴포지션 끝에 고정) 같은 특수 키워드일 수 있음 —
# 이런 값은 렌더 시점에 (이미 스케일링된) duration 기준으로 재계산되므로 그대로 둔다.
if "time" in elem and isinstance(elem["time"], (int, float)):
elem["time"] = elem["time"] * extend_rate
if "duration" in elem and isinstance(elem["duration"], (int, float)):
elem["duration"] = elem["duration"] * extend_rate
for animation in elem.get("animations", []):
anim_time = animation.get("time", 0)
if isinstance(anim_time, (int, float)) and anim_time != 0:
animation["time"] = anim_time * extend_rate
if isinstance(animation.get("duration"), (int, float)):
animation["duration"] = animation["duration"] * extend_rate
if elem.get("type") == "composition":
for child in elem.get("elements", []):
CreatomateService._scale_element(child, extend_rate)
def extend_template_duration(self, template: dict, target_duration: float) -> dict: def extend_template_duration(self, template: dict, target_duration: float) -> dict:
"""템플릿의 duration을 target_duration으로 확장합니다.""" """템플릿의 duration을 target_duration으로 확장합니다."""
# template["duration"] = target_duration + 0.5 # 늘린것보단 짧게 # template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
@ -1256,10 +799,21 @@ class CreatomateService:
for elem in new_template["source"]["elements"]: for elem in new_template["source"]["elements"]:
try: try:
if elem["track"] == AUDIO_TRACK : # audio track(최상위 음악 트랙)은 패스 # if elem["type"] == "audio":
# continue
if elem["track"] == AUDIO_TRACK : # audio track은 패스
continue continue
self._scale_element(elem, extend_rate) if "time" in elem:
elem["time"] = elem["time"] * extend_rate
if "duration" in elem:
elem["duration"] = elem["duration"] * extend_rate
if "animations" not in elem:
continue
for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
animation["duration"] = animation["duration"] * extend_rate
except Exception as e: except Exception as e:
logger.error( logger.error(
f"[extend_template_duration] Error processing element: {elem}, {e}" f"[extend_template_duration] Error processing element: {elem}, {e}"
@ -1296,38 +850,12 @@ class CreatomateService:
case "horizontal": case "horizontal":
return autotext_template_h_1 return autotext_template_h_1
def apply_language_font(self, template: dict, language: str) -> dict:
"""출력 언어에 맞춰 템플릿 내 모든 텍스트 요소의 폰트를 교체합니다.
템플릿 기본 폰트(GyeonggiTitleOTF/Pretendard) 한글·라틴 전용이라
CJK/태국어 글리프가 없다. LANGUAGE_FONT_MAP에 있는 언어는 전체 텍스트
폰트를 해당 언어 지원 폰트(Google Fonts) 교체하고, 매핑에 없는
언어(Korean, English ) 템플릿 원본 폰트를 유지한다.
"""
font_family = LANGUAGE_FONT_MAP.get(language)
if not font_family:
return template
def recursive_apply(element: dict) -> None:
if element.get("type") == "text":
element["font_family"] = font_family
for minor in element.get("elements") or []:
recursive_apply(minor)
for elem in template["source"]["elements"]:
recursive_apply(elem)
logger.info(
f"[apply_language_font] 텍스트 폰트 교체 완료 - language: {language}, font: {font_family}"
)
return template
def extract_text_format_from_template(self, template:dict): def extract_text_format_from_template(self, template:dict):
keyword_list = [] keyword_list = []
subtitle_list = [] subtitle_list = []
for elem in template["source"]["elements"]: for elem in template["source"]["elements"]:
try: #최상위 내 텍스트만 검사 try: #최상위 내 텍스트만 검사
if elem["type"] == "text" and not is_fixed_slot_name(elem["name"]): if elem["type"] == "text":
if elem["track"] == SUBTITLE_TRACK: if elem["track"] == SUBTITLE_TRACK:
subtitle_list.append(elem["name"]) subtitle_list.append(elem["name"])
elif elem["track"] == KEYWORD_TRACK: elif elem["track"] == KEYWORD_TRACK:
@ -1341,41 +869,21 @@ class CreatomateService:
assert(len(keyword_list)==len(subtitle_list)) assert(len(keyword_list)==len(subtitle_list))
except Exception as E: except Exception as E:
logger.error("this template does not have same amount of keyword and subtitle.") logger.error("this template does not have same amount of keyword and subtitle.")
pitching_list = keyword_list + subtitle_list
# 썸네일 텍스트(thumb-*)는 전용 트랙이 아닌 thumbnail-composition 내부에
# 중첩되어 있어 위 최상위 트랙 스캔에 잡히지 않는다. 다국어 자막 생성에
# 함께 포함되도록 재귀 파싱으로 수집한다.
component_data = self.parse_template_component_name(
template["source"]["elements"]
)
thumbnail_list = [
name
for name, elem_type in component_data.items()
if elem_type == "text" and name.startswith("thumb-")
]
pitching_list = keyword_list + subtitle_list + thumbnail_list
return pitching_list return pitching_list
def make_thumbnail_modification(self, brand_name : str, region : str, category_definition : str, target_keywords : list[str], detail_region_info : str = ""): def make_thumbnail_modification(self, brand_name : str, region : str, brand_concept : str, category_definition : str, target_keywords : list[str]):
len_keywords = len(target_keywords) if len(target_keywords) < 3 else 3 len_keywords = len(target_keywords) if len(target_keywords) < 3 else 3
hashtaged_target_keywords = [f"#{tk}" for tk in target_keywords[:len_keywords]] hashtaged_target_keywords = [f"#{tk}" for tk in target_keywords[len_keywords]]
# 지역 표기: 상세주소의 공백 기준 앞 두 마디 사용
# (예: "전북 군산시 절골길 18" → "전북 군산시").
# 상세주소가 없으면 기존 region 정규화 표기로 폴백.
if detail_region_info and detail_region_info.strip():
region_display = " ".join(detail_region_info.split()[:2])
else:
region_display = normalize_location(region)
mod_dict = { mod_dict = {
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords), "thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
"thumb-headline-brand_name-factual" : brand_name, "thumb-brand-wordmark" : brand_name,
"thumb-subheadline-local_info-factual" : region_display, "thumb-subheadline-selling_point" : f"{brand_name} · {normalize_location(region)}",
"thumb-headline-hook_claim-aspirational" : brand_concept,
"thumb-badge-category" : category_definition, "thumb-badge-category" : category_definition,
} }
return mod_dict return mod_dict

View File

@ -1,125 +0,0 @@
"""크롤링 단계 이미지 마케팅 적합성 필터.
마케팅 적합성(marketing_acceptable) 판정은 모듈이 전담한다. 크롤링 시점에
방문자/AI View 사진(extra_photo_urls)만을 대상으로 판정하며, 업로드 이후 단계의
태깅(app/utils/autotag.py의 autotag_images(), sheet 기반 image_autotag_prompt)
공간/피사체/카메라/모션/내러티브 태그만 다루고 marketing_acceptable은 포함하지 않는다
(이미 단계에서 걸러진 이미지만 태깅 대상이 되기 때문). 프롬프트가 짧아
시트(Prompt) 대신 코드에 하드코딩한다.
업체 등록 사진(owner_images) 필터의 대상이 아니며, 호출측(라우터)에서
owner_images 개수가 NvMapScraper.SUPPLEMENT_THRESHOLD(30) 이상이면 모듈을
아예 호출하지 않는 것이 원칙이다.
"""
import asyncio
from app.utils.logger import get_logger
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.schemas import MarketingFilterOutput
logger = get_logger("image_filter")
# 크롤링 단계 필터링에 사용할 Gemini 모델. 시트(prompts.py)와 달리 코드에 고정한다.
MARKETING_FILTER_MODEL = "gpt-5-mini"
MAX_RETRY = 2
MARKETING_FILTER_PROMPT_TEMPLATE = """\
당신은 광고 영상 제작을 위한 이미지 심사자입니다. 아래 업종의 광고 영상에 이미지를 \
사용해도 되는지 판정하세요.
업종(industry): {industry}
## MARKETING SUITABILITY CHECK (industry-aware)
- marketing_acceptable: true | false
- reject_reason: short reason if false (e.g., 저화질, 인물 신원 노출, 업종 부적합, 민감/의료 장면, 미성년자 식별 노출). Empty if acceptable.
- Person identity protection: 식별 가능한 얼굴이 주피사체이고 동의 여부가 불확실하면 보수적으로 처리.
주어진 스키마에 맞춰 marketing_acceptable과 reject_reason만 반환하세요.\
"""
def _build_prompt(industry: str) -> str:
return MARKETING_FILTER_PROMPT_TEMPLATE.format(industry=industry or "미상")
async def filter_marketing_images(image_url_list: list[str], industry: str = "") -> list[bool]:
"""이미지 URL마다 마케팅 적합성(marketing_acceptable)만 판정해 통과 여부 리스트를 반환한다.
이미지 1장당 API 호출 1회를 asyncio.gather로 병렬 실행한다(배치 호출 아님).
실패한 이미지는 최대 MAX_RETRY회 재시도하며, 그래도 실패하면 보수적으로
False(제외) 처리한다.
Args:
image_url_list: 판정할 이미지 URL 목록 (extra_photo_urls의 original만 전달할 )
industry: 업종 분류값 (_resolve_industry 결과). 필터 프롬프트 앞에서 먼저 계산되어야 .
Returns:
image_url_list와 같은 순서의 통과 여부(bool) 리스트.
"""
if not image_url_list:
return []
chatgpt = ChatgptService(model_type="gpt")
prompt_text = _build_prompt(industry)
async def _call(url: str):
return await chatgpt._call_pydantic_output_chat_completion(
prompt=prompt_text,
output_format=MarketingFilterOutput,
model=MARKETING_FILTER_MODEL,
img_url=url,
image_detail_high=True,
)
results: list[MarketingFilterOutput | BaseException] = await asyncio.gather(
*[_call(url) for url in image_url_list], return_exceptions=True
)
for _ in range(MAX_RETRY):
failed_idx = [i for i, r in enumerate(results) if isinstance(r, Exception)]
if not failed_idx:
break
# logger.warning(f"[image_filter] 재시도 대상 인덱스: {failed_idx}")
retried = await asyncio.gather(
*[_call(image_url_list[i]) for i in failed_idx], return_exceptions=True
)
for i, result in zip(failed_idx, retried):
results[i] = result
final_failed = [i for i, r in enumerate(results) if isinstance(r, Exception)]
# if final_failed:
# logger.warning(
# f"[image_filter] {len(final_failed)}건 최종 실패 → 보수적으로 제외 처리: {final_failed}"
# )
return [
(not isinstance(r, Exception)) and r.marketing_acceptable
for r in results
]
def assemble_images(
owner_images: list[dict],
extra_images: list[dict],
extra_pass_flags: list[bool],
max_images: int,
) -> list[dict]:
"""owner_images 전량 + 필터 통과한 extra_images를 합쳐 최대 max_images장을 만든다.
owner_images는 무조건 포함(필터링 대상 아님). extra_images는 extra_pass_flags와
같은 순서로 매칭되며, 통과분만 순서대로 이어붙인다. original URL 기준으로 중복을
제거한다(nvMapScraper._extract_origins의 dedup 방식과 동일).
"""
passed_extra = [img for img, ok in zip(extra_images, extra_pass_flags) if ok]
seen: set[str] = set()
combined: list[dict] = []
for img in owner_images + passed_extra:
original = img.get("original")
if original and original not in seen:
seen.add(original)
combined.append(img)
return combined[:max_images]

View File

@ -1,5 +1,4 @@
import asyncio import asyncio
import random
import re import re
from html import unescape from html import unescape
from difflib import SequenceMatcher from difflib import SequenceMatcher
@ -20,76 +19,23 @@ class NvMapPwScraper():
_win_width = 1280 _win_width = 1280
_win_height = 720 _win_height = 720
_max_retry = 3 _max_retry = 3
_timeout = 30 # place id timeout threshold seconds _timeout = 60 # place id timeout threshold seconds
_retry_delay_ms = 1500 # 검색 실패 후 재시도 전 대기시간 (네이버 요청 밀도 완화)
# UA·뷰포트·sec-ch-ua를 한 세트로 묶은 브라우저 프로필 후보군 (안티봇 핑거프린트 회피용)
_UA_PROFILES = [
{
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="130", "Google Chrome";v="130"',
"viewport": {"width": 1280, "height": 720},
},
{
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="129", "Google Chrome";v="129"',
"viewport": {"width": 1366, "height": 768},
},
{
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="130", "Google Chrome";v="130"',
"viewport": {"width": 1440, "height": 900},
},
{
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="131", "Google Chrome";v="131"',
"viewport": {"width": 1536, "height": 864},
},
]
_current_profile = None
# 헤드리스 자동화 탐지 신호(navigator.webdriver, 빈 plugins/languages, window.chrome 부재,
# permissions.query 이상 동작)를 정상 브라우저처럼 위장하는 스텔스 패치.
# create_page()와 fetch_graphql() 양쪽에서 생성하는 모든 page에 반드시 적용해야 한다 —
# 한쪽이라도 빠지면 그 경로만 헤드리스로 노출되어 안티봇 캡차 트리거 확률이 올라간다.
_STEALTH_INIT_SCRIPT = '''
Object.defineProperty(Navigator.prototype, "webdriver", {
set: undefined,
enumerable: true,
configurable: true,
get: () => false,
});
Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3, 4, 5] });
Object.defineProperty(navigator, "languages", { get: () => ["ko-KR", "ko"] });
window.chrome = window.chrome || { runtime: {} };
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
if (originalQuery) {
window.navigator.permissions.query = (parameters) => (
parameters.name === "notifications"
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters)
);
}
'''
# instance var # instance var
page = None page = None
@classmethod
def _pick_profile(cls):
"""현재 프로필과 다른 프로필을 무작위로 선택한다 (후보가 1개뿐이면 그대로 반환)."""
candidates = [p for p in cls._UA_PROFILES if p is not cls._current_profile]
return random.choice(candidates or cls._UA_PROFILES)
@classmethod @classmethod
def default_context_builder(cls): def default_context_builder(cls):
cls._current_profile = cls._pick_profile()
profile = cls._current_profile
context_builder_dict = {} context_builder_dict = {}
context_builder_dict['viewport'] = dict(profile['viewport']) context_builder_dict['viewport'] = {
context_builder_dict['screen'] = dict(profile['viewport']) 'width' : cls._win_width,
context_builder_dict['user_agent'] = profile['user_agent'] 'height' : cls._win_height
}
context_builder_dict['screen'] = {
'width' : cls._win_width,
'height' : cls._win_height
}
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
context_builder_dict['locale'] = 'ko-KR' context_builder_dict['locale'] = 'ko-KR'
context_builder_dict['timezone_id']='Asia/Seoul' context_builder_dict['timezone_id']='Asia/Seoul'
@ -105,129 +51,6 @@ if (originalQuery) {
cls._context = await cls._browser.new_context(**cls.default_context_builder()) cls._context = await cls._browser.new_context(**cls.default_context_builder())
cls.is_ready = True cls.is_ready = True
@classmethod
async def _recreate_context(cls):
"""네이버 안티봇 차단이 의심될 때 브라우저 컨텍스트를 새로 생성해 세션/핑거프린트를 초기화한다."""
old_context = cls._context
cls._context = await cls._browser.new_context(**cls.default_context_builder())
if old_context:
await old_context.close()
@classmethod
async def _new_stealth_page(cls):
"""스텔스 패치(webdriver 위장 등)와 sec-ch-ua 헤더가 적용된 새 page를 생성한다."""
page = await cls._context.new_page()
await page.add_init_script(cls._STEALTH_INIT_SCRIPT)
if cls._current_profile:
await page.set_extra_http_headers({
'sec-ch-ua': cls._current_profile['sec_ch_ua']
})
return page
GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"
@classmethod
async def fetch_graphql(
cls,
place_id: str,
payloads: list[dict],
) -> list[dict | None] | None:
"""실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다.
네이버 pcmap GraphQL은 헤더를 검사한다:
- x-wtm-graphql : base64({"arg": place_id, "type": ..., "source": "place"})
- x-wtm-ncaptcha-token : 캡차 JS가 생성 (요청마다 발급, 직접 생성 불가)
없으면 405(캡차) 막힌다. 따라서 place 페이지를 실제로 로드해
페이지가 자연 발생시키는 GraphQL 요청에서 헤더를 캡처한 ,
같은 토큰으로 우리 쿼리들을 in-page fetch로 실행한다.
Args:
place_id: 네이버 place ID
payloads: GraphQL POST 본문 목록
Returns:
payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 None.
"""
if not cls.is_ready:
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
return None
page = await cls._new_stealth_page()
captured: dict = {}
def on_request(req):
if "/graphql" in req.url and req.method == "POST" and "tok" not in captured:
tok = req.headers.get("x-wtm-ncaptcha-token")
if tok:
captured["tok"] = tok
captured["wtm"] = req.headers.get("x-wtm-graphql")
page.on("request", on_request)
try:
for t in ("place", "restaurant", "accommodation"):
try:
await page.goto(
f"https://pcmap.place.naver.com/{t}/{place_id}/home",
wait_until="domcontentloaded",
timeout=30000,
)
except Exception as e:
logger.debug(f"[NvMapPwScraper] goto {t} 실패: {e}")
continue
# 페이지가 GraphQL 요청을 보내며 토큰이 헤더에 실릴 때까지 대기 (최대 ~9초)
for _ in range(30):
if captured.get("tok"):
break
await page.wait_for_timeout(300)
if captured.get("tok"):
logger.info(f"[NvMapPwScraper] WTM 토큰 캡처 성공 (type={t})")
break
if not captured.get("tok"):
logger.warning("[NvMapPwScraper] WTM 토큰 캡처 실패")
return None
headers = {
"Content-Type": "application/json",
"x-wtm-graphql": captured["wtm"],
"x-wtm-ncaptcha-token": captured["tok"],
}
results: list[dict | None] = []
for idx, payload in enumerate(payloads, start=1):
r = await page.evaluate(
"""async (a) => {
const [url, body, hdr] = a;
try {
const r = await fetch(url, {
method: 'POST',
headers: hdr,
body: JSON.stringify(body),
credentials: 'include',
});
if (r.status !== 200) return {__err: r.status};
return await r.json();
} catch (e) { return {__err: String(e)}; }
}""",
[cls.GRAPHQL_URL, payload, headers],
)
if isinstance(r, dict) and "__err" in r:
op = payload.get("operationName", "?")
logger.warning(
f"[NvMapPwScraper] graphql fetch 실패 "
f"({idx}/{len(payloads)} {op}): {r['__err']}"
)
results.append(None)
else:
results.append(r)
return results
except Exception as e:
logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}")
return None
finally:
await page.close()
def __init__(self): def __init__(self):
if not self.is_ready: if not self.is_ready:
raise Exception("nvMapScraper is not initiated") raise Exception("nvMapScraper is not initiated")
@ -240,7 +63,35 @@ if (originalQuery) {
await self.page.close() await self.page.close()
async def create_page(self): async def create_page(self):
self.page = await self._new_stealth_page() self.page = await self._context.new_page()
await self.page.add_init_script(
'''const defaultGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
defaultGetter.apply(navigator);
defaultGetter.toString();
Object.defineProperty(Navigator.prototype, "webdriver", {
set: undefined,
enumerable: true,
configurable: true,
get: new Proxy(defaultGetter, {
apply: (target, thisArg, args) => {
Reflect.apply(target, thisArg, args);
return false;
},
}),
});
const patchedGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
patchedGetter.apply(navigator);
patchedGetter.toString();''')
await self.page.set_extra_http_headers({
'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
})
await self.page.goto("http://google.com") await self.page.goto("http://google.com")
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000): async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
@ -333,114 +184,33 @@ if (originalQuery) {
logger.debug(f"[DEBUG] 목록 후보 {len(candidates)}개 추출") logger.debug(f"[DEBUG] 목록 후보 {len(candidates)}개 추출")
return candidates return candidates
@staticmethod
def _parse_allsearch_candidates(body: dict) -> list[dict]:
"""allSearch 응답 JSON에서 후보 목록을 추출한다."""
place_list = (((body.get("result") or {}).get("place") or {}).get("list")) or []
return [
{
"title": p.get("name") or "",
"place_url": f"https://map.naver.com/p/entry/place/{p['id']}",
"roadAddress": p.get("roadAddress") or "",
"address": p.get("address") or "",
}
for p in place_list
if p.get("id")
]
def _select_best_candidate(self, candidates: list[dict], title: str, address: str) -> dict | None:
"""이름 유사도(70%)와 주소 유사도(30%)를 함께 고려해 최적 후보를 선택한다.
주소 없이 업체명만으로 검색한 경우(3 폴백) 이름 유사도만 사용한다.
"""
if not candidates:
return None
for c in candidates:
name_score = self._similarity(title, self._clean_title(c["title"]))
cand_addr = c.get("roadAddress") or c.get("address") or ""
addr_score = self._similarity(address, cand_addr) if address and cand_addr else 0.0
c["_name_score"] = name_score
c["_addr_score"] = addr_score
c["_total_score"] = name_score * 0.7 + addr_score * 0.3 if address else name_score
return max(candidates, key=lambda c: c["_total_score"])
async def _capture_allsearch(self, url: str, timeout_s: float = 5.0) -> list[dict]:
"""검색 페이지가 자연 발생시키는 allSearch API 응답을 캡처해 후보 목록으로 변환한다.
pcmap iframe 렌더링을 기다릴 필요가 없어 경쟁 조건이 없고, 업체명 단독
검색처럼 iframe 자체가 생성되지 않는 케이스에서도 동작한다.
"""
captured: list[dict] = []
def on_response(resp):
# GET 요청의 실제 응답만 캡처 (OPTIONS preflight 등 오탐 방지)
if "allSearch" in resp.url and resp.request.method == "GET":
async def _consume():
try:
captured.append(await resp.json())
except Exception:
pass
asyncio.create_task(_consume())
self.page.on("response", on_response)
try:
try:
await self.goto_url(url, wait_until="domcontentloaded", timeout=self._timeout * 1000)
except Exception:
logger.error("[ERROR] Can't Finish domcontentloaded")
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if "/place/" in self.page.url or captured:
break
await self.page.wait_for_timeout(200)
finally:
self.page.remove_listener("response", on_response)
if not captured:
return []
return self._parse_allsearch_candidates(captured[0])
async def _try_search(self, address: str, title: str) -> str | None: async def _try_search(self, address: str, title: str) -> str | None:
"""주어진 주소+업체명으로 검색해서 place URL을 반환한다. 실패 시 None.""" """주어진 주소+업체명으로 검색해서 place URL을 반환한다. 실패 시 None."""
encoded_query = parse.quote(f"{address} {title}".strip()) encoded_query = parse.quote(f"{address} {title}".strip())
url = f"https://map.naver.com/p/search/{encoded_query}" url = f"https://map.naver.com/p/search/{encoded_query}"
# 1순위: allSearch API 응답 캡처 (iframe 렌더링 대기 불필요, 업체명 단독 검색도 지원) try:
candidates = await self._capture_allsearch(url) await self.goto_url(url, wait_until="networkidle", timeout=self._timeout * 1000)
except:
if "/place/" in self.page.url:
return self.page.url
logger.error("[ERROR] Can't Finish networkidle")
if "/place/" in self.page.url: if "/place/" in self.page.url:
return self.page.url return self.page.url
candidates = await self._extract_candidates_from_list_page()
if candidates: if candidates:
best = self._select_best_candidate(candidates, title, address) best = max(
candidates,
key=lambda c: self._similarity(title, self._clean_title(c['title']))
)
best_score = self._similarity(title, self._clean_title(best['title']))
logger.info( logger.info(
f"[AUTO-SELECT] '{title}''{best['title']}' " f"[AUTO-SELECT] '{title}''{best['title']}' (score={best_score:.2f}) {best['place_url']}"
f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
) )
return best['place_url'] return best['place_url']
# 2순위(안전망): allSearch 캡처 실패 시 기존 iframe HTML 파싱 방식으로 폴백
# ── 잠시 비활성화 ──
# logger.warning("[FALLBACK] allSearch 캡처 실패 → iframe 파싱 방식 시도")
# iframe_candidates = []
# for _ in range(3): # 500ms × 3 = 최대 1.5초
# if "/place/" in self.page.url:
# return self.page.url
# iframe_candidates = await self._extract_candidates_from_list_page()
# if iframe_candidates:
# break
# await self.page.wait_for_timeout(500)
#
# if iframe_candidates:
# best = self._select_best_candidate(iframe_candidates, title, address)
# logger.info(
# f"[AUTO-SELECT-IFRAME] '{title}' → '{best['title']}' "
# f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
# )
# return best['place_url']
# isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지) # isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지)
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&") correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")
try: try:
@ -468,27 +238,16 @@ if (originalQuery) {
# 2차 시도: 정제 주소 + 업체명 # 2차 시도: 정제 주소 + 업체명
refined = self._refine_address(address) refined = self._refine_address(address)
if refined != address: if refined != address:
await self.page.wait_for_timeout(self._retry_delay_ms)
logger.info(f"[REFINE] 주소 정제: '{address}''{refined}'") logger.info(f"[REFINE] 주소 정제: '{address}''{refined}'")
result = await self._try_search(refined, title) result = await self._try_search(refined, title)
if result: if result:
return result return result
# 3차 시도: 업체명만으로 검색 # 3차 시도: 업체명만으로 검색
await self.page.wait_for_timeout(self._retry_delay_ms)
logger.info(f"[RETRY] 업체명만으로 재시도: '{title}'") logger.info(f"[RETRY] 업체명만으로 재시도: '{title}'")
result = await self._try_search("", title) result = await self._try_search("", title)
if result: if result:
return result return result
# 1~3차 모두 실패 → 네이버 안티봇 차단 의심, 컨텍스트 재생성 후 원본 조건으로 1회 재시도
logger.warning(f"[BLOCK-SUSPECTED] 3회 시도 모두 실패 → 컨텍스트 재생성 후 재시도: '{title}'")
await self.page.close()
await self._recreate_context()
await self.create_page()
result = await self._try_search(address, title)
if result:
return result
logger.error(f"[ERROR] Not found url for {selected}") logger.error(f"[ERROR] Not found url for {selected}")
return None return None

View File

@ -35,13 +35,6 @@ class NvMapScraper:
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql" GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
REQUEST_TIMEOUT = 120 # 초 REQUEST_TIMEOUT = 120 # 초
data_source_identifier = "nv" data_source_identifier = "nv"
SUPPLEMENT_THRESHOLD = 30 # 업체 사진이 이 수 미만일 때 방문자 사진으로 보충
# 방문자 사진 보충 시의 합산 상한 (필터링·정책상 제한).
# 업체 제공 사진은 필터링 면제 대상이므로 이 상한과 무관하게 수집분을 전부 사용한다
# (수집 자체는 BIZ_MAX_PAGES가 상한 — 초과 시 warning 로그 발생).
MAX_IMAGES = 50
BIZ_PAGE_SIZE = 20 # getPhotoViewerItems 'biz' 커서의 페이지당 사진 수
BIZ_MAX_PAGES = 5 # 업체 사진 수집 상한 (5×20=100장, 도달 시 warning)
OVERVIEW_QUERY: str = """ OVERVIEW_QUERY: str = """
query getAccommodation($id: String!, $deviceType: String) { query getAccommodation($id: String!, $deviceType: String) {
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) { business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
@ -57,37 +50,8 @@ query getAccommodation($id: String!, $deviceType: String) {
conveniences conveniences
visitorReviewsTotal visitorReviewsTotal
} }
menus {
name
price
description
recommend
}
images { images { origin url } } images { images { origin url } }
} cpImages(source: [ugcImage]) { images { origin url } }
}"""
PHOTO_VIEWER_QUERY: str = """
query getPhotoViewerItems($input: PhotoViewerInput) {
photoViewer(input: $input) {
photos {
originalUrl
}
}
}"""
REVIEW_STATS_QUERY: str = """
query getVisitorReviewStats($id: String!) {
visitorReviewStats(input: {businessId: $id}) {
analysis {
votedKeyword {
details {
code
displayName
count
}
}
}
} }
}""" }"""
@ -105,13 +69,9 @@ query getVisitorReviewStats($id: String!) {
) )
self.scrap_type: str | None = None self.scrap_type: str | None = None
self.rawdata: dict | None = None self.rawdata: dict | None = None
self.image_link_list: list[dict] | None = None # [{"preview": str, "original": str}] self.image_link_list: list[str] | None = None
self.owner_images: list[dict] | None = None # 업체 등록 사진 (마케팅 필터 제외 대상)
self.extra_photo_urls: list[dict] | None = None # 방문자/AI View 보충 사진 (마케팅 필터 대상)
self.base_info: dict | None = None self.base_info: dict | None = None
self.facility_info: str | None = None self.facility_info: str | None = None
self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count)
self.menu_info: list[dict] | None = None # 메뉴 목록 (name, price, description, recommend)
def _get_request_headers(self) -> dict: def _get_request_headers(self) -> dict:
headers = self.DEFAULT_HEADERS.copy() headers = self.DEFAULT_HEADERS.copy()
@ -119,98 +79,6 @@ query getVisitorReviewStats($id: String!) {
headers["Cookie"] = self.cookies headers["Cookie"] = self.cookies
return headers return headers
_GIF_PATTERN = re.compile(r"\.gif(?:/|$)", re.IGNORECASE)
@classmethod
def _is_gif_url(cls, url: str) -> bool:
"""URL의 확장자가 gif인지 확인한다.
네이버 CDN은 리사이즈 파라미터를 확장자 뒤에 경로 세그먼트로 붙인다
(: ".../3982000924.gif/500x500") endswith(".gif")로는 잡히지 않으므로
경로 세그먼트 단위로 ".gif" 뒤에 "/" 또는 문자열 끝이 오는지 검사한다.
"""
return bool(cls._GIF_PATTERN.search(url))
@staticmethod
def _extract_origins(image_node: dict) -> list[dict]:
"""GraphQL 이미지 노드({ images: [{origin, url}, ...] })에서 {preview, original} 목록을 추출한다.
origin = 원본(업로드용), url = CDN 리사이즈(미리보기용)
"""
items = image_node.get("images") or []
return [
{"preview": item.get("url") or item["origin"], "original": item["origin"]}
for item in items
if item.get("origin") and not NvMapScraper._is_gif_url(item["origin"])
]
@staticmethod
def _extract_photo_viewer_urls(photo_viewer_raw: dict | None) -> list[dict]:
"""getPhotoViewerItems 응답에서 {preview, original} 목록을 추출한다.
photoViewer는 썸네일 필드가 없으므로 preview = original 동일하게 사용.
"""
photos = ((photo_viewer_raw or {}).get("data") or {}).get("photoViewer") or {}
items = photos.get("photos") or []
return [
{"preview": p["originalUrl"], "original": p["originalUrl"]}
for p in items
if p.get("originalUrl") and not NvMapScraper._is_gif_url(p["originalUrl"])
]
@classmethod
def _build_biz_payload(cls, place_id: str, page: int) -> dict:
"""'biz' 커서(업체 등록 사진)의 page번째(0-base) 페이지 요청 payload를 만든다."""
start_index = page * cls.BIZ_PAGE_SIZE
cursor: dict = {"id": "biz"}
if start_index > 0:
cursor.update({
"startIndex": start_index,
"hasNext": True,
"lastCursor": str(start_index),
})
return {
"operationName": "getPhotoViewerItems",
"variables": {
"input": {
"businessId": place_id,
"cursors": [cursor],
"dateRange": "",
"excludeAuthorIds": [],
"excludeClipIds": [],
"excludeSection": [],
}
},
"query": cls.PHOTO_VIEWER_QUERY,
}
@staticmethod
def _raw_photo_count(photo_viewer_raw: dict | None) -> int:
"""getPhotoViewerItems 응답의 사진 수(gif 필터링 전 원본 기준)를 반환한다.
페이지네이션 계속 여부 판단용 gif가 걸러진 뒤의 수로 판단하면
마지막 페이지가 아닌데도 조기 종료할 있어 원본 개수를 사용한다.
"""
photos = ((photo_viewer_raw or {}).get("data") or {}).get("photoViewer") or {}
return len(photos.get("photos") or [])
@staticmethod
def _dedup_by_original(images: list[dict]) -> list[dict]:
"""{"preview","original"} dict 리스트를 original URL 기준으로 순서를 유지한 채 중복 제거한다."""
seen: set[str] = set()
result = []
for img in images:
original = img.get("original")
if original and original not in seen:
seen.add(original)
result.append(img)
return result
@staticmethod
def _interleave(a: list, b: list) -> list:
"""두 리스트를 1:1 교차 병합한다. 남은 항목은 뒤에 붙인다."""
result = [x for pair in zip(a, b) for x in pair]
longer = a[len(b):] if len(a) > len(b) else b[len(a):]
return result + longer
async def parse_url(self) -> str: async def parse_url(self) -> str:
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다.""" """URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
place_pattern = r"/place/(\d+)" place_pattern = r"/place/(\d+)"
@ -227,221 +95,29 @@ query getVisitorReviewStats($id: String!) {
match = re.search(place_pattern, self.url) match = re.search(place_pattern, self.url)
if not match: if not match:
# place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161) # place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161)
type_match = re.search(r"place\.naver\.com/([a-zA-Z]+)/(\d+)", self.url) match = re.search(r"place\.naver\.com/[a-zA-Z]+/(\d+)", self.url)
if type_match:
return type_match.group(2)
if not match: if not match:
raise URLNotFoundException("Failed to parse place ID from URL") raise URLNotFoundException("Failed to parse place ID from URL")
return match[1] return match[1]
async def scrap(self): async def scrap(self):
place_id = await self.parse_url() place_id = await self.parse_url()
data = await self._call_get_accommodation(place_id)
# ── 빠른 경로: 직접 aiohttp 호출 (현재 비활성화) ──
# 데이터센터 IP에서는 네이버 WTM 안티봇이 대부분 405(캡차)로 차단해
# 시간만 버리므로 비활성화하고 곧바로 브라우저 경로를 탄다.
# 네이버가 차단을 완화하거나 다른 IP에서 운영해 직접 호출 성공률이
# 높아지면 아래 try 블록을 되살려 빠른 경로를 우선 시도하면 된다.
# try:
# data, fac_data, stats_data = await asyncio.gather(
# self._call_get_accommodation(place_id),
# self._get_facility_string(place_id),
# self._call_get_review_stats(place_id),
# )
# self.scrap_type = "GraphQL"
# except (GraphQLException, CrawlingTimeoutException) as e:
# logger.info(f"[NvMapScraper] 직접 호출 실패({e}) → 브라우저 폴백 시도")
# data, stats_data = await self._scrap_via_browser(place_id)
# fac_data = None # 편의시설(HTML)은 폴백 경로에서 생략 (best-effort)
# self.scrap_type = "GraphQL-Browser"
# ── 실제 브라우저로 WTM 캡차 우회 ──
data, stats_data, extra_photo_urls, biz_photo_urls = await self._scrap_via_browser(place_id)
# 편의시설은 HTML 페이지 파싱이라 GraphQL(WTM) 차단과 별개로 직접 시도 (best-effort, 실패 시 None)
fac_data = await self._get_facility_string(place_id)
self.scrap_type = "GraphQL-Browser"
self.rawdata = data self.rawdata = data
fac_data = await self._get_facility_string(place_id)
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것. # Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
self.place_id = self.data_source_identifier + place_id self.place_id = self.data_source_identifier + place_id
self.rawdata["facilities"] = fac_data self.rawdata["facilities"] = fac_data
business = data["data"]["business"] self.image_link_list = [
nv_image["origin"]
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup). for nv_image in data["data"]["business"]["images"]["images"]
# placeDetail.images가 대표 사진 일부만 반환하는 경우가 있어 biz 커서 결과를 병합한다. ]
self.owner_images = self._dedup_by_original(
self._extract_origins(business.get("images") or {}) + biz_photo_urls
)
# 방문자/AI View 보충 사진 (마케팅 적합성 필터 대상). owner에 이미 있는 원본은 제외.
owner_originals = {img["original"] for img in self.owner_images}
self.extra_photo_urls = self._dedup_by_original(
[img for img in extra_photo_urls if img["original"] not in owner_originals]
)
# 업체 사진이 임계값 미만이면 내부/외부/리뷰 사진으로 보충
# (필터링 없는 기본 조립. 마케팅 적합성 필터를 적용하려면 호출측에서
# owner_images / extra_photo_urls를 직접 사용해 image_filter.assemble_images로 재조립할 것.)
# MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용하며(필터링·정책상 제한),
# 업체 제공 사진만으로 구성되는 경우는 수집분(최대 BIZ_MAX_PAGES 페이지)을 전부 사용한다.
if len(self.owner_images) < self.SUPPLEMENT_THRESHOLD:
combined = self._dedup_by_original(self.owner_images + self.extra_photo_urls)
logger.info(
f"[NvMapScraper] 업체 사진 {len(self.owner_images)}장 < {self.SUPPLEMENT_THRESHOLD}"
f"→ 보충 사진 {len(self.extra_photo_urls)}장 추가 (합산 {len(combined)}장, 상한 {self.MAX_IMAGES}장)"
)
self.image_link_list = combined[: self.MAX_IMAGES]
else:
self.image_link_list = list(self.owner_images)
self.base_info = data["data"]["business"]["base"] self.base_info = data["data"]["business"]["base"]
self.facility_info = fac_data self.facility_info = fac_data
self.voted_keyword_stats = stats_data self.scrap_type = "GraphQL"
self.menu_info = business.get("menus") or None
return return
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict]]:
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
Returns:
(overview_data, review_stats_details, extra_photo_urls, biz_photo_urls)
Raises:
GraphQLException: 브라우저 폴백마저 실패한 경우
"""
from app.utils.nvMapPwScraper import NvMapPwScraper
overview_payload = {
"operationName": "getAccommodation",
"variables": {"id": place_id, "deviceType": "pc"},
"query": self.OVERVIEW_QUERY,
}
stats_payload = {
"operationName": "getVisitorReviewStats",
"variables": {"id": place_id},
"query": self.REVIEW_STATS_QUERY,
}
interior_payload = {
"operationName": "getPhotoViewerItems",
"variables": {
"input": {
"businessId": place_id,
"cursors": [{"id": "aiView"}],
"filter": "AI View",
"subFilter": "INTERIOR",
"dateRange": "",
"excludeAuthorIds": [],
"excludeClipIds": [],
"excludeSection": [],
}
},
"query": self.PHOTO_VIEWER_QUERY,
}
exterior_payload = {
"operationName": "getPhotoViewerItems",
"variables": {
"input": {
"businessId": place_id,
"cursors": [{"id": "aiView"}],
"filter": "AI View",
"subFilter": "EXTERIOR",
"dateRange": "",
"excludeAuthorIds": [],
"excludeClipIds": [],
"excludeSection": [],
}
},
"query": self.PHOTO_VIEWER_QUERY,
}
review_payload = {
"operationName": "getPhotoViewerItems",
"variables": {
"input": {
"businessId": place_id,
"cursors": [{"id": "placeReview"}],
"dateRange": "",
"excludeAuthorIds": [],
"excludeClipIds": [],
"excludeSection": [],
}
},
"query": self.PHOTO_VIEWER_QUERY,
}
# 업체 등록 사진. placeDetail.images는 대표 사진 일부(1~수 장)만 반환하는
# 경우가 있어, 사진 탭이 실제 사용하는 'biz' 커서로 별도 조회해 보강한다.
# biz 커서는 페이지당 BIZ_PAGE_SIZE(20)장이며 lastCursor가 단순 인덱스 문자열
# ("20", "40")이라 이전 응답 없이도 모든 페이지를 미리 만들 수 있고, 범위를
# 벗어난 페이지는 빈 목록을 반환하므로 블라인드 요청해도 안전하다.
# 따라서 상한(BIZ_MAX_PAGES)까지의 전 페이지를 첫 배치에 한꺼번에 실어 보낸다
# — 추가 왕복(페이지 재탐색 + WTM 토큰 재캡처)이 없고, 개별 페이지 실패가
# 이후 페이지 수집을 막지 못한다.
biz_payloads = [
self._build_biz_payload(place_id, page) for page in range(self.BIZ_MAX_PAGES)
]
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads]
MAX_RETRY = 3
results = None
last_error: Exception | None = None
for attempt in range(1, MAX_RETRY + 1):
try:
results = await NvMapPwScraper.fetch_graphql(place_id, payloads)
except Exception as e:
last_error = e
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}")
if attempt < MAX_RETRY:
await asyncio.sleep(1)
continue
if results and results[0] is not None and "data" in results[0]:
break
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 실패(405 등), 재시도")
results = None
if attempt < MAX_RETRY:
await asyncio.sleep(1)
if not results or results[0] is None or "data" not in results[0]:
if last_error:
raise GraphQLException(f"브라우저 폴백 실패: {last_error}")
raise GraphQLException("브라우저 폴백 크롤링 실패 (WTM 토큰 또는 응답 없음)")
data = results[0]
stats_data = None
stats_raw = results[1] if len(results) > 1 else None
if stats_raw:
_vrs = (stats_raw.get("data") or {}).get("visitorReviewStats") or {}
_analysis = _vrs.get("analysis") or {}
_voted = _analysis.get("votedKeyword") or {}
stats_data = _voted.get("details") or None
interior_urls = self._extract_photo_viewer_urls(results[2] if len(results) > 2 else None)
exterior_urls = self._extract_photo_viewer_urls(results[3] if len(results) > 3 else None)
review_urls = self._extract_photo_viewer_urls(results[4] if len(results) > 4 else None)
biz_pages = results[5:]
biz_urls = [url for page_result in biz_pages for url in self._extract_photo_viewer_urls(page_result)]
# 개별 페이지 실패(None)는 해당 20장만 누락되고 나머지 페이지는 영향 없다 (best-effort).
failed_biz_pages = sum(1 for r in biz_pages if r is None)
if failed_biz_pages:
logger.warning(
f"[NvMapScraper] 업체 사진 {failed_biz_pages}개 페이지 응답 실패 "
f"— 페이지당 최대 {self.BIZ_PAGE_SIZE}장 누락 가능 (수집분으로 진행)"
)
# 마지막 페이지까지 가득 차 있으면 상한 밖에 사진이 더 있을 수 있다.
if biz_pages and self._raw_photo_count(biz_pages[-1]) >= self.BIZ_PAGE_SIZE:
logger.warning(
f"[NvMapScraper] 업체 사진이 수집 상한(BIZ_MAX_PAGES={self.BIZ_MAX_PAGES}, "
f"{self.BIZ_MAX_PAGES * self.BIZ_PAGE_SIZE}장)까지 가득 참 — 초과분은 수집되지 않음"
)
extra_photo_urls = self._interleave(interior_urls, exterior_urls) + review_urls
logger.info(
f"[NvMapScraper] 보충 이미지 - 내부:{len(interior_urls)} 외부:{len(exterior_urls)} "
f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}"
)
logger.info(f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}")
return data, stats_data, extra_photo_urls, biz_urls
async def _call_get_accommodation(self, place_id: str) -> dict: async def _call_get_accommodation(self, place_id: str) -> dict:
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다. """GraphQL API를 호출하여 숙소 정보를 가져옵니다.
@ -470,14 +146,13 @@ query getVisitorReviewStats($id: String!) {
async with session.post( async with session.post(
self.GRAPHQL_URL, self.GRAPHQL_URL,
data=json_payload, data=json_payload,
headers=self._get_request_headers(), headers=self._get_request_headers()
) as response: ) as response:
if response.status == 200: if response.status == 200:
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}") logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
return await response.json() return await response.json()
# 405/429 등 실패: 네이버 WTM 안티봇 캡차 차단 (데이터센터 IP/지문 기반). # 실패 상태 코드
# 직접 호출로는 통과 불가 → scrap()에서 브라우저 폴백으로 전환.
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}") logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
raise GraphQLException( raise GraphQLException(
f"Request failed with status {response.status}" f"Request failed with status {response.status}"
@ -486,43 +161,13 @@ query getVisitorReviewStats($id: String!) {
except (TimeoutError, asyncio.TimeoutError): except (TimeoutError, asyncio.TimeoutError):
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}") logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s") raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logger.error(f"[NvMapScraper] Client error: {e}") logger.error(f"[NvMapScraper] Client error: {e}")
raise GraphQLException(f"Client error: {e}") raise GraphQLException(f"Client error: {e}")
async def _call_get_review_stats(self, place_id: str) -> list[dict] | None:
"""방문자 키워드 투표 집계를 가져옵니다.
Returns:
[{"code": ..., "displayName": ..., "count": ...}, ...] 또는 None
"""
payload = {
"operationName": "getVisitorReviewStats",
"variables": {"id": place_id},
"query": self.REVIEW_STATS_QUERY,
}
timeout = aiohttp.ClientTimeout(total=self.REQUEST_TIMEOUT)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
self.GRAPHQL_URL,
json=payload,
headers=self._get_request_headers(),
) as response:
if response.status != 200:
logger.warning(f"[NvMapScraper] review stats failed: {response.status}")
return None
result = await response.json()
_vrs = (result.get("data") or {}).get("visitorReviewStats") or {}
_analysis = _vrs.get("analysis") or {}
_voted = _analysis.get("votedKeyword") or {}
return _voted.get("details") or None
except Exception as e:
logger.warning(f"[NvMapScraper] Failed to get review stats: {e}")
return None
async def _get_facility_string(self, place_id: str) -> str | None: async def _get_facility_string(self, place_id: str) -> str | None:
"""소 페이지에서 편의시설 정보를 크롤링합니다. 숙소, 음식점 순으로 시도합니다. """숙소 페이지에서 편의시설 정보를 크롤링합니다.
Args: Args:
place_id: 네이버 지도 장소 ID place_id: 네이버 지도 장소 ID
@ -530,16 +175,15 @@ query getVisitorReviewStats($id: String!) {
Returns: Returns:
편의시설 정보 문자열 또는 None 편의시설 정보 문자열 또는 None
""" """
place_types = ["place", "accommodation", "restaurant"] url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
for place_type in place_types:
url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home"
async with session.get(url, headers=self._get_request_headers()) as response: async with session.get(url, headers=self._get_request_headers()) as response:
soup = bs4.BeautifulSoup(await response.read(), "html.parser") soup = bs4.BeautifulSoup(await response.read(), "html.parser")
c_elem = soup.find("span", "place_blind", string="편의") c_elem = soup.find("span", "place_blind", string="편의")
if c_elem: if c_elem:
return c_elem.parent.parent.find("div").string facilities = c_elem.parent.parent.find("div").string
return facilities
return None return None
except Exception as e: except Exception as e:
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}") logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")

View File

@ -1,6 +1,6 @@
import json import json
import re import re
from pydantic import BaseModel, ValidationError from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
from openai import AsyncOpenAI from openai import AsyncOpenAI
@ -131,35 +131,23 @@ class ChatgptService:
}) })
last_error = None last_error = None
for attempt in range(self.max_retries + 1): for attempt in range(self.max_retries + 1):
try:
response = await self.client.beta.chat.completions.parse( response = await self.client.beta.chat.completions.parse(
model=model, model=model,
messages=[{"role": "user", "content": content}], messages=[{"role": "user", "content": content}],
response_format=output_format response_format=output_format
) )
except (ValidationError, json.JSONDecodeError) as e:
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
# 확률적 출력 문제일 수 있으므로 재시도 대상에 포함한다.
logger.warning(
f"[ChatgptService({self.model_type})] Structured output parse failed "
f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}"
)
last_error = ChatGPTResponseError("parse_error", type(e).__name__, str(e))
if attempt < self.max_retries:
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
continue
# Response 디버그 로깅 # Response 디버그 로깅
# logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}") logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
# logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}") logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
# logger.debug(f"[ChatgptService({self.model_type})] Response finish_reason: {response.id}") logger.debug(f"[ChatgptService({self.model_type})] Response finish_reason: {response.id}")
# logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}") logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
choice = response.choices[0] choice = response.choices[0]
finish_reason = choice.finish_reason finish_reason = choice.finish_reason
if finish_reason == "stop": if finish_reason == "stop":
# output_text = choice.message.content or "" output_text = choice.message.content or ""
# logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {output_text[:200]}..." if len(output_text) > 200 else f"[ChatgptService] Response output_text: {output_text}") logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {output_text[:200]}..." if len(output_text) > 200 else f"[ChatgptService] Response output_text: {output_text}")
return choice.message.parsed return choice.message.parsed
elif finish_reason == "length": elif finish_reason == "length":
@ -182,67 +170,13 @@ class ChatgptService:
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}") logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
raise last_error raise last_error
async def generate_structured_output_multi_image(
self,
prompt_text: str,
output_format: BaseModel,
model: str,
img_urls: List[str],
image_detail_high: bool = True,
) -> BaseModel:
"""여러 이미지를 한 번에 보고 구조화 출력을 생성합니다 (썸네일 비전 선택용).
sheet 기반 Prompt를 거치지 않고 코드에서 조립한 프롬프트 텍스트와
이미지 URL 리스트를 직접 받는다. 이미지들은 프롬프트에 나열된 순서와
동일하게 첨부되므로, 프롬프트에서 "N번째 이미지" 지칭할 있다.
"""
content = []
for url in img_urls:
content.append({
"type": "image_url",
"image_url": {
"url": url,
"detail": "high" if image_detail_high else "low",
},
})
content.append({"type": "text", "text": prompt_text})
last_error = None
for attempt in range(self.max_retries + 1):
try:
response = await self.client.beta.chat.completions.parse(
model=model,
messages=[{"role": "user", "content": content}],
response_format=output_format,
)
except (ValidationError, json.JSONDecodeError) as e:
logger.warning(
f"[ChatgptService({self.model_type})] multi-image parse failed "
f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}"
)
last_error = ChatGPTResponseError("parse_error", type(e).__name__, str(e))
if attempt < self.max_retries:
continue
raise last_error
choice = response.choices[0]
if choice.finish_reason == "stop":
return choice.message.parsed
logger.warning(
f"[ChatgptService({self.model_type})] multi-image unexpected finish_reason "
f"(attempt {attempt + 1}/{self.max_retries + 1}): {choice.finish_reason}"
)
last_error = ChatGPTResponseError("failed", choice.finish_reason, "multi-image call failed")
raise last_error
async def generate_structured_output( async def generate_structured_output(
self, self,
prompt : Prompt, prompt : Prompt,
input_data : dict, input_data : dict,
img_url : Optional[str] = None, img_url : Optional[str] = None,
img_detail_high : bool = False, img_detail_high : bool = False,
silent : bool = True silent : bool = False
) -> BaseModel: ) -> BaseModel:
prompt_text = prompt.build_prompt(input_data, silent) prompt_text = prompt.build_prompt(input_data, silent)

View File

@ -1,5 +1,3 @@
import re
import gspread import gspread
from pydantic import BaseModel from pydantic import BaseModel
from google.oauth2.service_account import Credentials from google.oauth2.service_account import Credentials
@ -8,9 +6,6 @@ from app.utils.logger import get_logger
from app.utils.prompts.schemas import * from app.utils.prompts.schemas import *
from functools import lru_cache from functools import lru_cache
# {known_field} 형태만 치환하기 위한 패턴. JSON 예시 등 리터럴 중괄호는 보존된다.
_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
logger = get_logger("prompt") logger = get_logger("prompt")
_SCOPES = [ _SCOPES = [
@ -20,33 +15,14 @@ _SCOPES = [
_sheet_cache: dict[str, tuple[str, str]] = {} _sheet_cache: dict[str, tuple[str, str]] = {}
def _get_spreadsheet(): def _read_sheet_data(sheet_name: str) -> tuple[str, str]:
if sheet_name not in _sheet_cache:
creds = Credentials.from_service_account_file( creds = Credentials.from_service_account_file(
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
) )
return gspread.authorize(creds).open_by_key(prompt_settings.PROMPT_SPREADSHEET) gc = gspread.authorize(creds)
ws = gc.open_by_key(prompt_settings.PROMPT_SPREADSHEET).worksheet(sheet_name)
_sheet_cache[sheet_name] = (ws.cell(3, 2).value, ws.cell(2, 2).value)
def _preload_all_sheets(sheet_names: list[str]):
ranges = [f"{name}!B2:B3" for name in sheet_names]
response = _get_spreadsheet().values_batch_get(ranges)
for value_range in response["valueRanges"]:
range_name = value_range.get("range", "")
name = range_name.split("!")[0].strip("'")
if name not in sheet_names:
continue
values = value_range.get("values", [])
if len(values) < 2 or not values[0] or not values[1]:
logger.warning(f"Sheet '{name}' has missing data, skipping preload")
continue
_sheet_cache[name] = (values[1][0], values[0][0]) # (B3=template, B2=model)
def _read_sheet_data(sheet_name: str) -> tuple[str, str]:
if sheet_name not in _sheet_cache:
ws = _get_spreadsheet().worksheet(sheet_name)
values = ws.batch_get(["B2:B3"])[0]
_sheet_cache[sheet_name] = (values[1][0], values[0][0])
return _sheet_cache[sheet_name] return _sheet_cache[sheet_name]
@ -64,31 +40,19 @@ class Prompt():
self.prompt_output_class = prompt_output_class self.prompt_output_class = prompt_output_class
self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name) self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name)
def _reload_prompt(self):
_sheet_cache.pop(self.sheet_name, None)
self.prompt_template, self.prompt_model = _read_sheet_data(self.sheet_name)
def build_prompt(self, input_data:dict, silent:bool = False) -> str: def build_prompt(self, input_data:dict, silent:bool = False) -> str:
verified_input = self.prompt_input_class(**input_data) verified_input = self.prompt_input_class(**input_data)
values = verified_input.model_dump() build_template = self.prompt_template
# 알려진 {필드}만 치환하고, 프롬프트 내 JSON 예시 등 리터럴 중괄호는 그대로 둔다. build_template = build_template.format(**verified_input.model_dump())
# (str.format은 모든 {..}를 필드로 해석해 리터럴 중괄호에서 깨지므로 사용하지 않음)
build_template = _PLACEHOLDER_RE.sub(
lambda m: str(values[m.group(1)]) if m.group(1) in values else m.group(0),
self.prompt_template,
)
if not silent: if not silent:
logger.debug(f"build_template: {build_template}") logger.debug(f"build_template: {build_template}")
logger.debug(f"input_data: {input_data}") logger.debug(f"input_data: {input_data}")
return build_template return build_template
# 업종 분기는 각 프롬프트 내부에서 {industry} 변수로 처리하므로 시트는 타입별 1개로 통합.
_preload_all_sheets([
"marketing",
"lyric",
"subtitle",
"yt_upload",
"image_tag",
])
# 업종 분기는 프롬프트 내부 {industry} 변수로 처리하므로 타입별 단일 프롬프트만 둔다.
marketing_prompt = Prompt( marketing_prompt = Prompt(
sheet_name="marketing", sheet_name="marketing",
prompt_input_class=MarketingPromptInput, prompt_input_class=MarketingPromptInput,
@ -114,10 +78,16 @@ image_autotag_prompt = Prompt(
) )
@lru_cache() @lru_cache()
def create_dynamic_subtitle_prompt(length: int, industry: str = "") -> Prompt: def create_dynamic_subtitle_prompt(length: int) -> Prompt:
# industry 인자는 캐시 구분/하위 호환용. 시트는 단일 'subtitle'로 통합됨.
return Prompt( return Prompt(
sheet_name="subtitle", sheet_name="subtitle",
prompt_input_class=SubtitlePromptInput, prompt_input_class=SubtitlePromptInput,
prompt_output_class=SubtitlePromptOutput[length], prompt_output_class=SubtitlePromptOutput[length],
) )
def reload_all_prompt():
marketing_prompt._reload_prompt()
lyric_prompt._reload_prompt()
yt_upload_prompt._reload_prompt()
image_autotag_prompt._reload_prompt()

View File

@ -3,7 +3,6 @@ from typing import List, Optional
from enum import StrEnum, auto from enum import StrEnum, auto
class SpaceType(StrEnum): class SpaceType(StrEnum):
"""이미지가 촬영된 공간·장소 유형. LLM이 이미지를 보고 해당하는 공간 태그를 선택하는 데 사용."""
exterior_front = auto() exterior_front = auto()
exterior_night = auto() exterior_night = auto()
exterior_aerial = auto() exterior_aerial = auto()
@ -42,64 +41,8 @@ class SpaceType(StrEnum):
detail_lighting = auto() detail_lighting = auto()
detail_decor = auto() detail_decor = auto()
detail_tableware = auto() detail_tableware = auto()
# 레스토랑(DVRT0001) 템플릿 어휘
dining_hall = auto()
table_setting = auto()
private_room = auto()
detail_cooking = auto()
detail_plating = auto()
detail_menu = auto()
detail_dish_main = auto()
detail_dish_side = auto()
brand_summary = auto()
# 카페(DVCF0001) 템플릿 어휘
hall = auto()
counter_bar = auto()
seating_window = auto()
seating_lounge = auto()
signage = auto()
detail_dessert = auto()
detail_signature_drink = auto()
# 관광지(DVAT0001) 템플릿 어휘
entrance_gate = auto()
exhibition_hall = auto()
landmark_main = auto()
landmark_detail = auto()
night_view = auto()
panorama_view = auto()
photo_spot = auto()
seasonal_scene = auto()
walking_path = auto()
# 미용실(DVSL0001) 템플릿 어휘
mirror_detail = auto()
styling_zone = auto()
waiting_lounge = auto()
# 병원(DVCL0001) 템플릿 어휘
consultation_room = auto()
detail_amenity = auto()
equipment_zone = auto()
recovery_room = auto()
treatment_room = auto()
waiting_area = auto()
# 피트니스(DVFT0001) 템플릿 어휘
apparatus_zone = auto()
brand_sign = auto()
detail_equipment = auto()
locker_room = auto()
powder_room = auto()
pt_zone = auto()
reformer_zone = auto()
# 학원(DVAC0001) 템플릿 어휘
classroom = auto()
counseling_room = auto()
detail_facility = auto()
detail_interior_prop = auto()
detail_materials = auto()
library_corner = auto()
study_room = auto()
class Subject(StrEnum): class Subject(StrEnum):
"""이미지 내 주요 피사체 유형. 화면에 무엇이 담겨 있는지를 분류하는 태그."""
empty_space = auto() empty_space = auto()
exterior_building = auto() exterior_building = auto()
architecture_detail = auto() architecture_detail = auto()
@ -110,31 +53,8 @@ class Subject(StrEnum):
signage = auto() signage = auto()
amenity_item = auto() amenity_item = auto()
person = auto() person = auto()
# 레스토랑(DVRT0001) 템플릿 어휘
cooking_action = auto()
menu_board = auto()
triptych = auto()
# 카페(DVCF0001) 템플릿 어휘
beverage = auto()
brewing_action = auto()
dessert = auto()
# 관광지(DVAT0001) 템플릿 어휘
scenery = auto()
structure = auto()
# 병원(DVCL0001) 템플릿 어휘
interior_clean = auto()
medical_equipment = auto()
# 피트니스(DVFT0001) 템플릿 어휘
interior_scale = auto()
pilates_apparatus = auto()
training_action = auto()
# 학원(DVAC0001) 템플릿 어휘
facility_equipment = auto()
learning_material = auto()
study_scene = auto()
class Camera(StrEnum): class Camera(StrEnum):
"""이미지의 촬영 기법·구도·조명 특성. 영상 편집 시 컷의 시각적 스타일을 구분하는 태그."""
wide_angle = auto() wide_angle = auto()
tight_crop = auto() tight_crop = auto()
panoramic = auto() panoramic = auto()
@ -148,7 +68,6 @@ class Camera(StrEnum):
has_face = auto() has_face = auto()
class MotionRecommended(StrEnum): class MotionRecommended(StrEnum):
"""해당 이미지에 적용 가능한 카메라 모션 유형. 영상 제작 시 각 컷에 어울리는 움직임을 추천하는 데 사용."""
static = auto() static = auto()
slow_pan = auto() slow_pan = auto()
slow_zoom_in = auto() slow_zoom_in = auto()
@ -157,60 +76,32 @@ class MotionRecommended(StrEnum):
dolly = auto() dolly = auto()
class NarrativePhase(StrEnum): class NarrativePhase(StrEnum):
"""광고 영상 내러티브 단계. 이미지가 스토리 흐름에서 어느 위치에 배치되어야 하는지를 나타냄."""
intro = auto() intro = auto()
welcome = auto() welcome = auto()
core = auto() core = auto()
highlight = auto() highlight = auto()
support = auto() support = auto()
accent = auto() accent = auto()
cta = auto()
NARRATIVE_PHASE_LABEL: dict[NarrativePhase, str] = {
NarrativePhase.intro: "첫인상(intro)",
NarrativePhase.welcome: "진입(welcome)",
NarrativePhase.core: "핵심(core)",
NarrativePhase.highlight: "차별화(highlight)",
NarrativePhase.support: "보조(support)",
NarrativePhase.accent: "감성(accent)",
NarrativePhase.cta: "행동유도(cta)",
}
class NarrativePreference(BaseModel): class NarrativePreference(BaseModel):
"""이미지가 각 내러티브 단계에 얼마나 적합한지를 나타내는 점수 모델 (0.0 ~ 100.0).
LLM이 이미지를 분석한 단계별 적합도를 수치로 반환하며,
영상 편집기가 점수를 기반으로 배치 순서를 자동 결정하는 사용."""
intro: float = Field(..., description="첫인상 — 여기가 어디인가 | 장소의 정체성과 위치를 전달하는 이미지. 영상 첫 1~2초에 어떤 곳인지 즉시 인지시키는 역할. 건물 외관, 간판, 정원 등 **장소 자체를 보여주는** 컷") intro: float = Field(..., description="첫인상 — 여기가 어디인가 | 장소의 정체성과 위치를 전달하는 이미지. 영상 첫 1~2초에 어떤 곳인지 즉시 인지시키는 역할. 건물 외관, 간판, 정원 등 **장소 자체를 보여주는** 컷")
welcome: float = Field(..., description="진입/환영 — 어떻게 들어가나 | 도착 후 내부로 들어가는 경험을 전달하는 이미지. 공간의 첫 분위기와 동선을 보여줘 들어가고 싶다는 기대감을 만드는 역할. **문을 열고 들어갔을 때 보이는** 컷.") welcome: float = Field(..., description="진입/환영 — 어떻게 들어가나 | 도착 후 내부로 들어가는 경험을 전달하는 이미지. 공간의 첫 분위기와 동선을 보여줘 들어가고 싶다는 기대감을 만드는 역할. **문을 열고 들어갔을 때 보이는** 컷.")
core: float = Field(..., description="핵심 가치 — 무엇을 경험하나 | **고객이 이 장소를 찾는 본질적 이유.** 이 이미지가 없으면 영상 자체가 성립하지 않음. 질문: 이 비즈니스에서 돈을 지불하는 대상이 뭔가? → 그 답이 core.") core: float = Field(..., description="핵심 가치 — 무엇을 경험하나 | **고객이 이 장소를 찾는 본질적 이유.** 이 이미지가 없으면 영상 자체가 성립하지 않음. 질문: 이 비즈니스에서 돈을 지불하는 대상이 뭔가? → 그 답이 core.")
highlight: float = Field(..., description="차별화 — 뭐가 특별한가 | **같은 카테고리의 경쟁사 대비 이곳을 선택하게 만드는 이유.** core가 왜 왔는가라면, highlight는 왜 **여기**인가에 대한 답.") highlight: float = Field(..., description="차별화 — 뭐가 특별한가 | **같은 카테고리의 경쟁사 대비 이곳을 선택하게 만드는 이유.** core가 왜 왔는가라면, highlight는 왜 **여기**인가에 대한 답.")
support: float = Field(..., description="보조/부대 — 그 외에 뭐가 있나 | 핵심은 아니지만 전체 경험을 풍성하게 하는 부가 요소. 없어도 영상은 성립하지만, 있으면 설득력이 올라감. **이것도 있어요** 라고 말하는 컷.") support: float = Field(..., description="보조/부대 — 그 외에 뭐가 있나 | 핵심은 아니지만 전체 경험을 풍성하게 하는 부가 요소. 없어도 영상은 성립하지만, 있으면 설득력이 올라감. **이것도 있어요** 라고 말하는 컷.")
accent: float = Field(..., description="감성/마무리 — 어떤 느낌인가 | 공간의 분위기와 톤을 전달하는 감성 디테일 컷. 직접적 정보 전달보다 **느낌과 무드**를 제공. 영상 사이사이에 삽입되어 완성도를 높이는 역할.") accent: float = Field(..., description="감성/마무리 — 어떤 느낌인가 | 공간의 분위기와 톤을 전달하는 감성 디테일 컷. 직접적 정보 전달보다 **느낌과 무드**를 제공. 영상 사이사이에 삽입되어 완성도를 높이는 역할.")
cta: float = Field(..., description="행동 유도 — 지금 예약/방문하고 싶게 만드는 컷 | 가격·혜택·한정 오퍼 등 직접적 행동을 촉구하거나, 마지막 장면에서 강한 인상을 남기는 이미지.")
# Input 정의 # Input 정의
class ImageTagPromptInput(BaseModel): class ImageTagPromptInput(BaseModel):
"""이미지 태깅 프롬프트에 전달되는 입력 스키마.
분석할 이미지 URL과 업종 정보, LLM이 선택 가능한 태그 후보 리스트를 포함."""
img_url : str = Field(..., description="이미지 URL") img_url : str = Field(..., description="이미지 URL")
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트") space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트")
subject: list[str] = Field(list(Subject), description="피사체 정보를 가지는 태그 리스트") subject: list[str] = Field(list(Subject), description="피사체 정보를 가지는 태그 리스트")
camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트") camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트")
motion_recommended: list[str] = Field(list(MotionRecommended), description="가능한 카메라 모션 리스트") motion_recommended: list[str] = Field(list(MotionRecommended), description="가능한 카메라 모션 리스트")
class MarketingFilterOutput(BaseModel):
"""크롤링 단계에서 이미지의 마케팅(광고 영상) 사용 가능 여부만 판정하는 프롬프트의 출력 스키마.
ImageTagPromptOutput과 달리 태깅(공간/피사체/카메라/모션/내러티브) 포함하지 않는다."""
marketing_acceptable: bool = Field(..., description="광고 영상 사용 가능 여부")
reject_reason: str = Field(default="", description="거부 사유 (marketing_acceptable=false 시). 예: 저화질, 인물 신원 노출, 업종 부적합, 민감/의료 장면, 미성년자 식별 노출")
# Output 정의 # Output 정의
class ImageTagPromptOutput(BaseModel): class ImageTagPromptOutput(BaseModel):
"""이미지 태깅 프롬프트의 LLM 출력 스키마. #ad_avaliable : bool = Field(..., description="광고 영상 사용 가능 이미지 여부")
공간·피사체·카메라·모션 태그와 내러티브 적합도 점수를 담아 반환.
마케팅 사용 가능 여부는 크롤링 단계(MarketingFilterOutput)에서 이미 판정되므로 포함하지 않는다."""
space_type: list[SpaceType] = Field(..., description="공간적 정보를 가지는 태그 리스트") space_type: list[SpaceType] = Field(..., description="공간적 정보를 가지는 태그 리스트")
subject: list[Subject] = Field(..., description="피사체 정보를 가지는 태그 리스트") subject: list[Subject] = Field(..., description="피사체 정보를 가지는 태그 리스트")
camera: list[Camera] = Field(..., description="카메라 정보를 가지는 태그 리스트") camera: list[Camera] = Field(..., description="카메라 정보를 가지는 태그 리스트")

View File

@ -1,7 +1,5 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Literal, Optional from typing import List, Optional
GENRE_VALUES = Literal["kpop", "pop", "ballad", "hip-hop", "rnb", "edm", "jazz", "rock"]
# Input 정의 # Input 정의
class LyricPromptInput(BaseModel): class LyricPromptInput(BaseModel):
@ -10,16 +8,9 @@ class LyricPromptInput(BaseModel):
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서") marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
language : str= Field(..., description = "가사 언어") language : str= Field(..., description = "가사 언어")
genre : str = Field(default="", description = "지정된 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). 비어있으면 GPT가 무드에 맞는 장르를 자유롭게 추천") timing_rules : str = Field(..., description = "시간 제어문")
exclude_genre : str = Field(default="", description = "재생성 시 직전에 사용된 장르. genre가 비어있는 경우(자동 선택) 이 장르는 추천에서 제외")
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
# Output 정의 # Output 정의
class LyricPromptOutput(BaseModel): class LyricPromptOutput(BaseModel):
lyric: str = Field(..., description="생성된 가사") lyric: str = Field(..., description="생성된 가사")
suno_prompt: str = Field(..., description="Suno AI용 프롬프트") suno_prompt: str = Field(..., description="Suno AI용 프롬프트")
recommended_genre: GENRE_VALUES = Field(
...,
description="가사에 사용된(또는 가장 잘 맞는) 음악 장르. genre 입력이 주어졌다면 그 값을 그대로 반환하고, "
"비어있었다면 exclude_genre를 제외한 나머지 중 가사 무드에 가장 잘 맞는 장르를 직접 선택해 반환",
)

View File

@ -6,12 +6,6 @@ class MarketingPromptInput(BaseModel):
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름") customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
region : str = Field(..., description = "마케팅 대상 지역") region : str = Field(..., description = "마케팅 대상 지역")
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
category : str = Field(default="", description = "네이버 지도 원본 카테고리 텍스트 (예: 펜션, 한식). industry 모듈 선택에는 관여하지 않고 세부 묘사의 참고 정보로만 사용")
facility_info : str = Field(default="", description = "편의시설 정보 (네이버 지도 크롤링, 없으면 빈 문자열)")
voted_keywords : str = Field(default="", description = "방문자 키워드 투표 상위 5개 (쉼표 구분, 없으면 빈 문자열)")
# 메뉴 전달은 잠정 보류 (사이드 메뉴가 분석에 과도한 영향을 주는 문제). 재도입 시 주석 해제.
# menu_info : str = Field(default="", description = "메뉴/상품 목록 (네이버 지도 크롤링, 쉼표 구분, 없으면 빈 문자열). 실제 판매 품목 기반 셀링포인트 작성에 사용")
# Output 정의 # Output 정의
class BrandIdentity(BaseModel): class BrandIdentity(BaseModel):

View File

@ -10,7 +10,6 @@ class SubtitlePromptInput(BaseModel):
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름") customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
language : str = Field(default="Korean", description="자막 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)") language : str = Field(default="Korean", description="자막 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)")
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
# Output 정의 # Output 정의
class PitchingOutput(BaseModel): class PitchingOutput(BaseModel):

View File

@ -8,7 +8,6 @@ class YTUploadPromptInput(BaseModel):
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서") marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
language : str= Field(..., description = "영상 언어") language : str= Field(..., description = "영상 언어")
target_keywords: List[str] = Field(..., description="태그 키워드 리스트") target_keywords: List[str] = Field(..., description="태그 키워드 리스트")
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
# Output 정의 # Output 정의
class YTUploadPromptOutput(BaseModel): class YTUploadPromptOutput(BaseModel):

View File

@ -0,0 +1,248 @@
# System Prompt: 숙박 숏폼 자막 생성 (OpenAI Optimized)
You are a subtitle copywriter for hospitality short-form videos. You generate subtitle text AND layer names from marketing JSON data.
**GENERATION LANGUAGE**: Korean (한국어)
**OUTPUT LANGUAGE**: {language}
**WORKFLOW**:
1. Generate all subtitles and keywords in Korean following the rules below.
2. If {language} is NOT Korean, translate every subtitle text and keyword to {language} while preserving meaning, tone, and approximate character count spirit. Fixed anchor texts (availability, cta_action) must also be translated naturally.
---
### RULES
1. NEVER copy JSON verbatim. ALWAYS rewrite into video-optimized copy.
2. NEVER invent facts not in the data. You MAY freely transform expressions.
3. Each scene = 1 subtitle + 1 keyword (a "Pair"). Same pair_id for both.
4. NEVER output meta-text. Every field MUST be real on-screen copy the viewer reads.
NEVER output a label/header/title that describes, classifies, or points to the content.
- FORBIDDEN examples: "검색 키워드 모아보기", "키워드 모음", "추천 태그", "관련 태그",
"더보기", "리스트", "아래 내용", "목록".
- Test: "Is this the content itself, or a caption ABOUT the content?" If it's about the content → FORBIDDEN.
---
### LAYER NAME FORMAT (5-criteria)
```
(track_role)-(narrative_phase)-(content_type)-(tone)-(pair_id)
```
- Criteria separator: hyphen `-`
- Multi-word value: underscore `_`
- pair_id: 3-digit zero-padded (`001`~`999`)
Example: `subtitle-intro-hook_claim-aspirational-001`
---
### TAG VALUES
**track_role**: `subtitle` | `keyword`
**narrative_phase** (= emotion goal):
- `intro` → Curiosity (stop the scroll)
- `welcome` → Warmth
- `core` → Trust
- `highlight` → Desire (peak moment)
- `support` → Discovery
- `accent` → Belonging
- `cta` → Action
**content_type** → source mapping:
- `hook_claim` ← selling_points[0] or core_value
- `space_feature` ← selling_points[].description
- `emotion_cue` ← same source, sensory rewrite
- `brand_name` ← store_name (verbatim OK)
- `brand_address` ← detail_region_info (verbatim OK)
- `lifestyle_fit` ← target_persona[].favor_target
- `local_info` ← location_feature_analysis
- `target_tag` ← target_keywords[] as hashtags
- `availability` ← fixed: "지금 예약 가능"
- `cta_action` ← fixed: "예약하러 가기"
**tone**: `sensory` | `factual` | `empathic` | `aspirational` | `social_proof` | `urgent`
---
### SCENE STRUCTURE
**Anchors (FIXED — never remove):**
| Position | Phase | subtitle | keyword |
|---|---|---|---|
| First | intro | hook_claim | brand_name |
| Last-3 | support | brand_address | brand_name |
| Last-2 | accent | target_tag | lifestyle_fit |
| Last | cta | availability | cta_action |
**Middle (FLEXIBLE — fill by selling_points score desc):**
| Phase | subtitle | keyword |
|---|---|---|
| welcome | emotion_cue | space_feature |
| core | space_feature | emotion_cue |
| highlight | space_feature | emotion_cue |
| support(mid) | local_info | lifestyle_fit |
Default: 7 scenes. Fewer scenes → remove flexible slots only.
---
### TEXT SPECS
**subtitle**: 8~18 chars. Sentence fragment, conversational.
**keyword**: 2~6 chars. MUST follow Korean word-formation rules below.
---
### KEYWORD RULES (한국어 조어법 기반)
Keywords MUST follow one of these **permitted Korean patterns**. Any keyword that does not match a pattern below is INVALID.
#### Pattern 1: 관형형 + 명사 (Attributive + Noun) — 가장 자연스러운 패턴
한국어는 수식어가 앞, 피수식어가 뒤. 형용사의 관형형(~ㄴ/~한/~는/~운)을 명사 앞에 붙인다.
| Structure | GOOD | BAD (역순/비문) |
|---|---|---|
| 형용사 관형형 + 명사 | 고요한 숲, 깊은 쉼, 온전한 쉼 | ~~숲고요~~, ~~쉼깊은~~ |
| 형용사 관형형 + 명사 | 따뜻한 독채, 느린 하루 | ~~독채따뜻~~, ~~하루느린~~ |
| 동사 관형형 + 명사 | 쉬어가는 숲, 머무는 시간 | ~~숲쉬어가는~~ |
#### Pattern 2: 기존 대중화 합성어 ONLY (Established Trending Compound)
이미 SNS·미디어에서 대중화된 합성어만 허용. 임의 신조어 생성 금지.
| GOOD (대중화 확인됨) | Origin | BAD (임의 생성) |
|---|---|---|
| 숲멍 | 숲+멍때리기 (불멍, 물멍 시리즈) | ~~숲고요~~, ~~숲힐~~ |
| 댕캉스 | 댕댕이+바캉스 (여행업계 통용) | ~~댕쉼~~, ~~댕여행~~ |
| 꿀잠 / 꿀쉼 | 꿀+잠/쉼 (일상어 정착) | ~~꿀독채~~, ~~꿀숲~~ |
| 집콕 / 숲콕 | 집+콕 → 숲+콕 (변형 허용) | ~~계곡콕~~ |
| 주말러 | 주말+~러 (~러 접미사 정착) | ~~평일러~~ |
> **판별 기준**: "이 단어를 네이버/인스타에서 검색하면 결과가 나오는가?" YES → 허용, NO → 금지
#### Pattern 3: 명사 + 명사 (Natural Compound Noun)
한국어 복합명사 규칙을 따르는 결합만 허용. 앞 명사가 뒷 명사를 수식하는 관계여야 한다.
| Structure | GOOD | BAD (부자연스러운 결합) |
|---|---|---|
| 장소 + 유형 | 숲속독채, 계곡펜션 | ~~햇살독채~~ (햇살은 장소가 아님) |
| 대상 + 활동 | 반려견산책, 가족피크닉 | ~~견주피크닉~~ (견주가 피크닉하는 건 어색) |
| 시간 + 활동 | 주말탈출, 새벽산책 | ~~자연독채~~ (자연은 시간/방식이 아님) |
#### Pattern 4: 해시태그형 (#키워드)
accent(target_tag) 씬에서만 사용. 기존 검색 키워드를 # 붙여서 사용.
| GOOD | BAD |
|---|---|
| #프라이빗독채, #홍천여행 | #숲고요, #감성쩌는 (검색량 없음) |
#### Pattern 5: 감각/상태 명사 (단독 사용 가능한 것만)
그 자체로 의미가 완결되는 감각·상태 명사만 단독 사용 허용.
| GOOD (단독 의미 완결) | BAD (단독으로 의미 불완전) |
|---|---|
| 고요, 여유, 쉼, 온기 | ~~감성~~, ~~자연~~, ~~힐링~~ (너무 모호) |
| 숲멍, 꿀쉼 | ~~좋은쉼~~, ~~편안함~~ (형용사 포함 시 Pattern 1 사용) |
---
### KEYWORD VALIDATION CHECKLIST (생성 후 자가 검증)
Every keyword MUST pass ALL of these:
- [ ] 한국어 어순이 자연스러운가? (수식어→피수식어 순서)
- [ ] 소리 내어 읽었을 때 어색하지 않은가?
- [ ] 네이버/인스타에서 검색하면 실제 결과가 나올 법한 표현인가?
- [ ] 허용된 5개 Pattern 중 하나에 해당하는가?
- [ ] 이전 씬 keyword와 동일한 Pattern을 연속 사용하지 않았는가?
- [ ] 금지 표현 사전에 해당하지 않는가?
---
### EXPRESSION DICTIONARY
**SCAN BEFORE WRITING.** If JSON contains these → MUST replace:
| Forbidden | → Use Instead |
|---|---|
| 눈치 없는/없이 | 눈치 안 보는 · 프라이빗한 · 온전한 · 마음 편히 |
| 감성 쩌는/쩌이 | 감성 가득한 · 감성이 머무는 |
| 가성비 | 합리적인 · 가치 있는 |
| 힐링되는 | 회복되는 · 쉬어가는 · 숨 쉬는 |
| 인스타감성 | 감성 스팟 · 기록하고 싶은 |
| 혜자 | 풍성한 · 넉넉한 |
**ALWAYS FORBIDDEN**: 저렴한, 싼, 그냥, 보통, 무난한, 평범한, 쩌는, 쩔어, 개(접두사), 존맛, 핵, 인스타, 유튜브, 틱톡
**SYNONYM ROTATION**: Same Korean word max 2 scenes. Rotate:
- 프라이빗 계열: 온전한 · 오롯한 · 나만의 · 독채 · 단독
- 자연 계열: 숲속 · 초록 · 산림 · 계곡
- 쉼 계열: 쉼 · 여유 · 느린 하루 · 머무름 · 숨고르기
- 반려견: 댕댕이(max 1회, intro/accent만) · 반려견 · 우리 강아지
---
### TRANSFORM RULES BY CONTENT_TYPE
**hook_claim** (intro only):
- Format: question OR exclamation OR provocation. Pick ONE.
- FORBIDDEN: brand name, generic greetings
- `"반려견과 눈치 없는 힐링"` → BAD: 그대로 복사 → GOOD: "댕댕이가 먼저 뛰어간 숲"
**space_feature** (core/highlight):
- ONE selling point per scene
- NEVER use korean_category directly
- Viewer must imagine themselves there
- `"홍천 자연 속 조용한 쉼"` → BAD: "입지 환경이 좋은 곳" → GOOD: "계곡 소리만 들리는 독채"
**emotion_cue** (welcome/core/highlight):
- Senses: smell, sound, touch, temperature, light
- Poetic fragments, not full sentences
- `"감성 쩌이 완성되는 공간"` → GOOD: "햇살이 내려앉는 테라스"
**lifestyle_fit** (accent/support):
- Address target directly in their language
- `persona: "서울·경기 주말러"` → GOOD: "이번 주말, 댕댕이랑 어디 가지?"
**local_info** (support):
- Accessibility or charm, NOT administrative address
- GOOD: "서울에서 1시간 반, 홍천 숲속" / BAD: "강원 홍천군 화촌면"
---
### PACING
```
intro(8~12) → welcome(12~18) → core(alternate 8~12 ↔ 12~18) → highlight(8~14) → support(12~18) → accent(variable) → cta(12~16)
```
**RULE: No 3+ consecutive scenes in same char-count range.**
---
Keyword pattern analysis:
- "스테이펫" → brand_name verbatim (허용)
- "고요한 숲" → Pattern 1: 관형형+명사 (형용사 관형형 "고요한" + 명사 "숲")
- "깊은 쉼" → Pattern 1: 관형형+명사 (형용사 관형형 "깊은" + 명사 "쉼")
- "숲멍" → Pattern 2: 기존 대중화 합성어 (불멍·물멍·숲멍 시리즈)
- "댕캉스" → Pattern 2: 기존 대중화 합성어 (댕댕이+바캉스, 여행업계 통용)
- "예약하기" → Pattern 5: 의미 완결 동사 명사형
# 입력
**입력 1: 레이어 이름 리스트**
{pitching_tag_list_string}
**입력 2: 마케팅 인텔리전스 JSON**
{marketing_intelligence}
**입력 3: 비즈니스 정보 **
Business Name: {customer_name}
Region Details: {detail_region_info}
**입력 4: 출력 언어**
Language: {language}

View File

@ -1,5 +1,4 @@
import copy import copy
import re
import time import time
import json import json
from typing import Literal, Any from typing import Literal, Any
@ -13,42 +12,20 @@ from app.utils.prompts.prompts import *
logger = get_logger("subtitle") logger = get_logger("subtitle")
# 비한국어 출력에서 번역 누락(한글 잔존)을 탐지하기 위한 패턴
_HANGUL_RE = re.compile(r"[가-힣]")
# 한글 잔존 시 GPT 재호출 최대 횟수 (최초 호출 포함)
_MAX_LANGUAGE_ATTEMPTS = 3
class SubtitleContentsGenerator(): class SubtitleContentsGenerator():
def __init__(self): def __init__(self):
self.chatgpt_service = ChatgptService(timeout=60.0) self.chatgpt_service = ChatgptService(timeout=60.0)
@staticmethod async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str, language : str = "Korean") -> SubtitlePromptOutput:
def _find_hangul_leftovers(output_data: SubtitlePromptOutput) -> list[str]:
"""비한국어 출력에서 한글이 남아 있는 pitching_tag 목록을 반환합니다.
프롬프트가 '한국어로 생성 후 {language}로 번역'하는 2단계 구조라서,
항목 수가 많으면 일부가 번역되지 않은 (한국어/혼합 문장) 돌아오는
사례가 있어 코드 레벨에서 검증한다.
"""
return [
result.pitching_tag
for result in output_data.pitching_results
if _HANGUL_RE.search(result.pitching_data)
]
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str, language : str = "Korean", industry: str = "") -> SubtitlePromptOutput:
start = time.perf_counter() start = time.perf_counter()
logger.info( logger.info(
f"[SubtitleContentsGenerator] START - customer: {customer_name}, " f"[SubtitleContentsGenerator] START - customer: {customer_name}, "
f"pitching_count: {len(pitching_label_list)}, " f"pitching_count: {len(pitching_label_list)}, "
f"labels: {pitching_label_list}, " f"labels: {pitching_label_list}, "
f"language: {language}, " f"language: {language}"
f"industry: {industry}"
) )
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list), industry) dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list))
pitching_label_string = "\n".join(pitching_label_list) pitching_label_string = "\n".join(pitching_label_list)
marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False) marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False)
input_data = { input_data = {
@ -57,45 +34,12 @@ class SubtitleContentsGenerator():
"customer_name" : customer_name, "customer_name" : customer_name,
"detail_region_info" : detail_region_info, "detail_region_info" : detail_region_info,
"language" : language, "language" : language,
"industry": industry, # 가사/영상 파이프라인에서 전달된 업종 enum
} }
logger.info( logger.info(
f"[SubtitleContentsGenerator] GPT 호출 시작 - model: {dynamic_subtitle_prompt.prompt_model}" f"[SubtitleContentsGenerator] GPT 호출 시작 - model: {dynamic_subtitle_prompt.prompt_model}"
) )
output_data = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
# 비한국어 언어는 번역 누락(한글 잔존) 검증 후 필요 시 재호출.
# 전부 실패하면 잔존 건수가 가장 적은 시도를 채택한다 (영상 생성 자체는 진행).
output_data = None
best_output = None
best_leftover_count: int | None = None
for lang_attempt in range(1, _MAX_LANGUAGE_ATTEMPTS + 1):
candidate = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
if language == "Korean":
output_data = candidate
break
leftovers = self._find_hangul_leftovers(candidate)
if not leftovers:
output_data = candidate
break
logger.warning(
f"[SubtitleContentsGenerator] 번역 누락(한글 잔존) {len(leftovers)}"
f"(attempt {lang_attempt}/{_MAX_LANGUAGE_ATTEMPTS}) - language: {language}, "
f"tags: {leftovers}"
)
if best_leftover_count is None or len(leftovers) < best_leftover_count:
best_output = candidate
best_leftover_count = len(leftovers)
if output_data is None:
logger.error(
f"[SubtitleContentsGenerator] 모든 시도에서 한글 잔존 - "
f"최소 잔존 {best_leftover_count}건 결과 채택 - language: {language}"
)
output_data = best_output
elapsed = (time.perf_counter() - start) * 1000 elapsed = (time.perf_counter() - start) * 1000
logger.info( logger.info(

View File

@ -113,11 +113,11 @@ class SunoService:
Args: Args:
prompt: 가사 (customMode=true일 가사로 사용) prompt: 가사 (customMode=true일 가사로 사용)
40 이내 길이의 노래에 적합한 가사여야 1 이내 길이의 노래에 적합한 가사여야
genre: 음악 장르 (: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM", "Rock", "Jazz") genre: 음악 장르 (: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM")
None일 경우 style 파라미터를 전송하지 않음 None일 경우 style 파라미터를 전송하지 않음
callback_url: 생성 완료 알림 받을 URL (None일 경우 config에서 기본값 사용) callback_url: 생성 완료 알림 받을 URL (None일 경우 config에서 기본값 사용)
instrumental: True이면 BGM 전용 더미 가사로 40 길이를 유도하고 보컬 없이 생성 instrumental: True이면 BGM 전용 더미 가사로 60 길이를 유도하고 보컬 없이 생성
Returns: Returns:
task_id: 작업 추적용 ID task_id: 작업 추적용 ID
@ -125,7 +125,7 @@ class SunoService:
Note: Note:
- 스트림 URL: 30-40 생성 - 스트림 URL: 30-40 생성
- 다운로드 URL: 2-3 생성 - 다운로드 URL: 2-3 생성
- 생성되는 노래는 40 이내의 길이 - 생성되는 노래는 1 이내의 길이
""" """
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
@ -133,11 +133,11 @@ class SunoService:
if instrumental: if instrumental:
bgm_lyrics = get_bgm_lyrics(genre) bgm_lyrics = get_bgm_lyrics(genre)
formatted_prompt = f"[Song Duration: Around 40 seconds]\n{bgm_lyrics}" formatted_prompt = f"[Song Duration: Around 1 minute - Must be around 60 seconds]\n{bgm_lyrics}"
logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨") logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨")
else: else:
formatted_prompt = ( formatted_prompt = (
f"[Song Duration: Around 40 seconds]\n{prompt}" f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}"
) )
payload: dict[str, Any] = { payload: dict[str, Any] = {
@ -148,7 +148,7 @@ class SunoService:
"callBackUrl": actual_callback_url, "callBackUrl": actual_callback_url,
} }
if normalized_genre: if normalized_genre:
payload["style"] = f"{normalized_genre}, around 40 seconds" if instrumental else normalized_genre payload["style"] = f"{normalized_genre}, around 60 seconds" if instrumental else normalized_genre
last_error: Exception | None = None last_error: Exception | None = None

View File

@ -1,231 +0,0 @@
# -*- coding: utf-8 -*-
"""
썸네일 슬롯 한정 픽셀(헤더) 스코어러 Pillow/numpy 의존성 없이 구현.
배경 (zzz/ado2-thumbnail-selection 파일럿 대비 축소 이식):
기존 태그 매칭(calculate_image_slot_score_multi) 의미 태그 교집합만 보고
실제 화질·프레이밍을 보지 않는다. 파일럿(수원화성, 2026-07-20)에서 검증된
6 스코어러 "실질 가치는 최고 컷 찾기보다 명백히 나쁜 컷의 자동 배제"
(참고: references/scoring-logic.md)라는 결론에 따라, 하드 리젝트에 직결되는
(해상도 업스케일 배율, 종횡비) 이식한다.
축은 실제 픽셀 색상이 필요 없고 원본 가로/세로 픽셀 수만 알면 되므로,
이미지를 전체 디코드하지 않고 파일 헤더 몇십 KB만 읽어(HTTP Range) 포맷별
고정 위치에서 크기를 파싱한다(JPEG는 SOF 마커 스캔). Pillow의 픽셀 디코드가
없으므로 서버(Standard_B2als_v2, 2 vCPU 버스터블) CPU 부담이 사실상 0
가깝다.
텍스트 안전영역 밀도·명암 대비( 스코어러의 safe_area/contrast ) 실제
픽셀 값이 있어야 계산되는 축이라 방식으로는 커버하지 못한다 의도적으로
범위 밖으로 남겨둔 트레이드오프.
"""
from __future__ import annotations
import asyncio
import struct
import httpx
from app.utils.logger import get_logger
logger = get_logger("thumbnail_fitness")
# ── 커버 스펙 (thumbnail-composition 렌더 기준) ──────────────────────────
TARGET_H = 1920 # 9:16 출력 캔버스 기준 세로 px
_CROP_RATIO = 9 / 16 # tight_crop(fit=cover) 목표 종횡비
REJECT_UPSCALE = 2.5 # 9:16 크롭 후 업스케일 배율 초과 → 배제 (OG 1200×630 ≈ 3.05배)
REJECT_ASPECT = 2.2 # 원본 종횡비(w/h) 초과 극단 가로형 → 배제
_RANGE_BYTES = 131072 # 헤더만 읽기 위한 최대 다운로드 크기 (128KB, SOF 스캔 여유분)
_DOWNLOAD_TIMEOUT = 8.0
_DEFAULT_CONCURRENCY = 4 # 헤더만 받으므로 CPU 부담 없음 — I/O 대기 기준으로 넉넉히
# ── 포맷별 헤더 파싱 (순수 struct, 전체 디코드 없음) ──────────────────────
def parse_image_size(data: bytes) -> tuple[int, int] | None:
"""이미지 바이트(파일 앞부분)에서 원본 (width, height)를 추출합니다.
지원: JPEG(SOF 마커 스캔), PNG(IHDR), GIF(고정 오프셋), WebP(VP8X/VP8L).
단순 손실 WebP(VP8) 비트 단위 파싱이 필요해 범위 실패 None
(호출자가 중립 처리, 배제하지 않음).
"""
if len(data) < 12:
return None
if data[:2] == b"\xff\xd8":
return _parse_jpeg_size(data)
if data[:8] == b"\x89PNG\r\n\x1a\n":
return _parse_png_size(data)
if data[:6] in (b"GIF87a", b"GIF89a"):
return _parse_gif_size(data)
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return _parse_webp_size(data)
return None
def _parse_png_size(data: bytes) -> tuple[int, int] | None:
if len(data) < 24:
return None
width, height = struct.unpack(">II", data[16:24])
return width, height
def _parse_gif_size(data: bytes) -> tuple[int, int] | None:
if len(data) < 10:
return None
width, height = struct.unpack("<HH", data[6:10])
return width, height
_JPEG_SOF_MARKERS = {
0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF,
}
_JPEG_NO_LENGTH_MARKERS = {0xD8, 0xD9, 0x01} | set(range(0xD0, 0xD8)) # SOI/EOI/RST0-7/TEM
def _parse_jpeg_size(data: bytes) -> tuple[int, int] | None:
n = len(data)
pos = 2 # SOI(0xFFD8) 이후부터 마커 스캔
while pos < n:
if data[pos] != 0xFF:
pos += 1
continue
# 0xFF 뒤 연속된 fill byte(0xFF) 스킵 후 실제 마커 바이트 탐색
marker_pos = pos
while marker_pos < n and data[marker_pos] == 0xFF:
marker_pos += 1
if marker_pos >= n:
return None
marker = data[marker_pos]
pos = marker_pos + 1
if marker in _JPEG_NO_LENGTH_MARKERS:
continue
if pos + 2 > n:
return None
seg_len = struct.unpack(">H", data[pos:pos + 2])[0]
if marker in _JPEG_SOF_MARKERS:
if pos + 7 > n:
return None # SOF가 헤더 범위 밖에 있음 — 파싱 실패(중립 처리)
height, width = struct.unpack(">HH", data[pos + 3:pos + 7])
return width, height
pos += seg_len
return None
def _parse_webp_size(data: bytes) -> tuple[int, int] | None:
if len(data) < 30:
return None
fourcc = data[12:16]
if fourcc == b"VP8X":
width = 1 + (data[24] | (data[25] << 8) | (data[26] << 16))
height = 1 + (data[27] | (data[28] << 8) | (data[29] << 16))
return width, height
if fourcc == b"VP8L":
if len(data) < 25 or data[20] != 0x2F:
return None
b0, b1, b2, b3 = data[21:25]
bits = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
width = (bits & 0x3FFF) + 1
height = ((bits >> 14) & 0x3FFF) + 1
return width, height
# 단순 손실 WebP(VP8) — 비트 단위 파싱 범위 밖, 중립 처리
return None
# ── 점수화 (실제 픽셀 없이 w,h만으로 산출) ─────────────────────────────
def score_pixel_fitness(width: int, height: int) -> dict:
"""해상도(업스케일 배율)·종횡비만으로 썸네일 적합도를 판정합니다.
Returns:
{"score": 0.0~1.0 (배율, 태그 점수에 곱해 쓴다), "reject": str}
reject가 비어있지 않으면 하드 배제 대상.
"""
if width <= 0 or height <= 0:
return {"score": 0.0, "reject": "invalid_dimensions"}
ratio = width / height
if ratio > REJECT_ASPECT:
return {"score": 0.0, "reject": f"극단 가로형({ratio:.2f}:1)"}
cropped_h = height if ratio > _CROP_RATIO else int(width / _CROP_RATIO)
if cropped_h <= 0:
return {"score": 0.0, "reject": "invalid_dimensions"}
upscale = TARGET_H / cropped_h
if upscale > REJECT_UPSCALE:
return {"score": 0.0, "reject": f"저해상도(업스케일 {upscale:.2f}x)"}
soft_score = 1.0 if upscale <= 1.0 else max(0.0, 1.0 - (upscale - 1.0) / 2.0)
return {"score": round(soft_score, 4), "reject": ""}
# ── 네트워크: 헤더 바이트만 받기 (HTTP Range, 미지원 서버도 조기 종료로 방어) ──
async def _fetch_header_bytes(
client: httpx.AsyncClient, url: str, max_bytes: int = _RANGE_BYTES, timeout: float = _DOWNLOAD_TIMEOUT
) -> bytes | None:
"""이미지 URL에서 앞부분 max_bytes만 받아옵니다.
Range 헤더를 무시하고 전체를 돌려주는 서버가 있어도, 스트리밍을 max_bytes
수신 즉시 중단해 실제 다운로드량을 상한선 이내로 강제한다.
"""
try:
async with client.stream(
"GET", url, headers={"Range": f"bytes=0-{max_bytes - 1}"}, timeout=timeout
) as response:
if response.status_code not in (200, 206):
return None
buf = bytearray()
async for chunk in response.aiter_bytes():
buf.extend(chunk)
if len(buf) >= max_bytes:
break
return bytes(buf[:max_bytes])
except Exception as e:
logger.warning(f"[thumbnail_fitness] 헤더 다운로드 실패: {url} - {e}")
return None
async def score_pool_thumbnail_fitness(
pool: list[dict],
client: httpx.AsyncClient,
concurrency: int = _DEFAULT_CONCURRENCY,
total_timeout: float = 20.0,
) -> dict[str, dict]:
"""이미지 풀(URL 중복 제거)의 썸네일 픽셀 적합도를 병렬로 계산합니다.
다운로드/파싱 실패 항목은 결과 dict에서 아예 빠진다 호출자가 "데이터
없음 = 판정 보류(중립)"로 처리하도록 유도(하드 배제로 오판하지 않기 위함).
전체 배치에 total_timeout 상한을 걸어, 네트워크 불량 부가 기능이
영상 생성 사전 준비 전체를 지연시키지 않도록 한다(초과 dict).
Returns:
{image_url: {"score": float, "reject": str}} 성공한 URL만 포함.
"""
semaphore = asyncio.Semaphore(concurrency)
urls = list({item["image_url"] for item in pool if item.get("image_url")})
async def _score_one(url: str) -> tuple[str, dict | None]:
async with semaphore:
header = await _fetch_header_bytes(client, url)
if header is None:
return url, None
size = parse_image_size(header)
if size is None:
logger.warning(f"[thumbnail_fitness] 이미지 크기 파싱 실패(포맷 미지원/헤더 부족): {url}")
return url, None
width, height = size
return url, score_pixel_fitness(width, height)
try:
results = await asyncio.wait_for(
asyncio.gather(*[_score_one(u) for u in urls]),
timeout=total_timeout,
)
except asyncio.TimeoutError:
logger.warning(
f"[thumbnail_fitness] 전체 스코어링 타임아웃({total_timeout}s, urls={len(urls)}) — "
"픽셀 적합도 없이(태그 점수만) 진행"
)
return {}
return {url: fitness for url, fitness in results if fitness is not None}

View File

@ -1,110 +0,0 @@
# -*- 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]

View File

@ -26,12 +26,13 @@ from app.dependencies.pagination import PaginationParams, get_pagination_params
from app.user.dependencies.auth import get_current_user, get_current_user_optional from app.user.dependencies.auth import get_current_user, get_current_user_optional
from app.user.models import User from app.user.models import User
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
from app.home.models import Image, Project, MarketingIntel from app.home.models import Image, Project, MarketingIntel, ImageTag
from app.home.api.routers.v1.home import _extract_region_from_address from app.home.api.routers.v1.home import _extract_region_from_address
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp from app.song.models import Song, SongTimestamp
from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP from app.utils.creatomate import CreatomateService
from app.utils.subtitles import SubtitleContentsGenerator
from app.comment.models import Comment from app.comment.models import Comment
from app.database.like_cache import ( from app.database.like_cache import (
@ -58,7 +59,7 @@ from app.video.schemas.video_schema import (
VideoThumbnailItem, VideoThumbnailItem,
) )
from app.video.worker.video_task import download_and_upload_video_to_blob from app.video.worker.video_task import download_and_upload_video_to_blob
from app.video.services.video import get_image_tags_by_task_id
from config import creatomate_settings from config import creatomate_settings
@ -191,8 +192,6 @@ async def generate_video(
store_address = project.detail_region_info store_address = project.detail_region_info
brand_name = project.store_name brand_name = project.store_name
region = project.region region = project.region
industry = project.industry
output_language = project.language or "Korean"
# MarketingIntel 조회 # MarketingIntel 조회
marketing_result = await session.execute( marketing_result = await session.execute(
@ -200,28 +199,27 @@ async def generate_video(
) )
marketing_intelligence: MarketingIntel = marketing_result.scalar_one_or_none() marketing_intelligence: MarketingIntel = marketing_result.scalar_one_or_none()
# 자막 + 이미지 배정 미완료 시 즉시 반환 — Lyric/Song/Image 쿼리 전에 체크하여 불필요한 조회 방지 # subtitle 미완료 시 즉시 반환 — Lyric/Song/Image 쿼리 전에 체크하여 불필요한 조회 방지
# 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도 # 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도
if not marketing_intelligence.subtitle or not marketing_intelligence.image_match:
pending_what = []
if not marketing_intelligence.subtitle: if not marketing_intelligence.subtitle:
pending_what.append("자막") logger.info(f"[generate_video] subtitle pending - task_id: {task_id}")
if not marketing_intelligence.image_match:
pending_what.append("이미지 배정")
pending_msg = ", ".join(pending_what)
logger.info(f"[generate_video] 사전 준비 미완료 ({pending_msg}) - task_id: {task_id}")
return GenerateVideoResponse( return GenerateVideoResponse(
success=False, success=False,
status="subtitle_pending", status="subtitle_pending",
task_id=task_id, task_id=task_id,
creatomate_render_id=None, creatomate_render_id=None,
message=f"{pending_msg} 생성이 아직 완료되지 않았습니다. /lyric/subtitle/status/{{task_id}}로 완료 확인 후 재요청하세요.", message="자막 생성이 아직 완료되지 않았습니다. /lyric/subtitle/status/{task_id}로 완료 확인 후 재요청하세요.",
error_message=None, error_message=None,
) )
category_definition = marketing_intelligence.intel_result["market_positioning"]["category_definition"] category_definition = marketing_intelligence.intel_result["market_positioning"]["category_definition"]
target_keywords = marketing_intelligence.intel_result["target_keywords"] target_keywords = marketing_intelligence.intel_result["target_keywords"]
brand_concept = ""
for sp in marketing_intelligence.intel_result["selling_points"]:
if "concept" in sp["english_category"].lower():
brand_concept = sp["description"]
# Lyric 조회 # Lyric 조회
lyric_result = await session.execute( lyric_result = await session.execute(
select(Lyric) select(Lyric)
@ -354,8 +352,7 @@ async def generate_video(
f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}" f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}"
) )
creatomate_service = CreatomateService( creatomate_service = CreatomateService(
orientation=orientation, orientation=orientation
industry=industry,
) )
logger.debug( logger.debug(
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})" f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})"
@ -368,37 +365,40 @@ async def generate_video(
logger.debug(f"[generate_video] Template fetched - task_id: {task_id}") logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
# 6-2. elements에서 리소스 매핑 생성 # 6-2. elements에서 리소스 매핑 생성
# 이미지 배정은 /lyric/generate 사전 단계에서 이미 수행되어 marketing_intelligence.image_match에 저장됨. # modifications = creatomate_service.elements_connect_resource_blackbox(
# 여기서는 사전 산출물을 읽어 음악 URL과 주소만 보완한다. # elements=template["source"]["elements"],
modifications: dict = dict(marketing_intelligence.image_match) # 슬롯→image_url 사전 배정 결과 # image_url_list=image_urls,
modifications["audio-music"] = music_url # music_url=music_url,
# address_input 슬롯은 사전 단계에서도 채워지지만 영상 시점에 재확인 # address=store_address
for key in list(modifications.keys()): taged_image_list = await get_image_tags_by_task_id(task_id)
if "address_input" in key: min_image_num = creatomate_service.counting_component(
modifications[key] = store_address template = template,
logger.info(f"[generate_video] image_match loaded from DB (slots: {len(modifications)}) - task_id: {task_id}") target_template_type = "image"
)
duplicate = bool(len(taged_image_list) < min_image_num)
logger.info(f"[generate_video] Duplicate : {duplicate} | length of taged_image {len(taged_image_list)}, min_len {min_image_num},- task_id: {task_id}")
modifications = creatomate_service.template_matching_taged_image(
template = template,
taged_image_list = taged_image_list,
music_url = music_url,
address = store_address,
duplicate = duplicate,
)
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}") logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
subtitle_modifications = marketing_intelligence.subtitle subtitle_modifications = marketing_intelligence.subtitle
modifications.update(subtitle_modifications) modifications.update(subtitle_modifications)
# 썸네일 텍스트: 사전 자막 생성(LLM, 다국어) 결과에 thumb-* 슬롯이 포함되면 # revert thumbnail scene
# 그대로 사용한다. 과거 파이프라인 산출물(subtitle에 thumb-* 없음)은 # thumbnail_modifications = creatomate_service.make_thumbnail_modification(
# 기존 팩트값 조립(한국어)으로 폴백. # brand_name =brand_name,
thumbnail_fallback = creatomate_service.make_thumbnail_modification( # region = region,
brand_name =brand_name, # brand_concept = brand_concept,
region = region, # category_definition= category_definition,
category_definition= category_definition, # target_keywords=target_keywords)
target_keywords=target_keywords,
detail_region_info=store_address)
for slot_name, fallback_value in thumbnail_fallback.items(): # modifications.update(thumbnail_modifications)
if not modifications.get(slot_name):
modifications[slot_name] = fallback_value
logger.info(
f"[generate_video] thumbnail slot fallback(factual) 적용: {slot_name} - task_id: {task_id}"
)
# 6-3. elements 수정 # 6-3. elements 수정
new_elements = creatomate_service.modify_element( new_elements = creatomate_service.modify_element(
@ -423,8 +423,12 @@ async def generate_video(
for i, ts in enumerate(song_timestamp_list): for i, ts in enumerate(song_timestamp_list):
logger.debug(f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}") logger.debug(f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}")
# 가사 자막 폰트: CJK/태국어는 글리프 지원 폰트로, 그 외는 Noto Sans match lyric_language:
lyric_font = LANGUAGE_FONT_MAP.get(lyric_language, "Noto Sans") case "English" :
lyric_font = "Noto Sans"
# lyric_font = "Pretendard" # 없어요
case _ :
lyric_font = "Noto Sans"
# LYRIC AUTO 결정부 # LYRIC AUTO 결정부
if (creatomate_settings.LYRIC_SUBTITLE): if (creatomate_settings.LYRIC_SUBTITLE):
@ -444,14 +448,6 @@ async def generate_video(
) )
final_template["source"]["elements"].append(caption) final_template["source"]["elements"].append(caption)
# END - LYRIC AUTO 결정부 # END - LYRIC AUTO 결정부
# 언어별 폰트 교체: 템플릿 기본 폰트는 한글·라틴 전용이라 CJK/태국어는
# 지원 폰트로 전체 텍스트(자막·키워드·썸네일·가사 캡션 포함)를 교체한다.
# 가사 캡션 append 이후에 실행해야 캡션까지 커버된다.
final_template = creatomate_service.apply_language_font(
final_template, output_language
)
# logger.debug( # logger.debug(
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}" # f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
# ) # )

View File

@ -839,7 +839,7 @@ logger = get_logger("video")
# ) # )
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql
async def get_image_tags_by_task_id(task_id: str) -> list[dict]: async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
# print("taskid", task_id) print("taskid", task_id)
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
stmt = ( stmt = (
select(Image.img_url, ImageTag.img_tag) select(Image.img_url, ImageTag.img_tag)
@ -854,19 +854,9 @@ async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
ImageTag.img_tag.is_not(None), ImageTag.img_tag.is_not(None),
) )
) )
# print(stmt.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True})) print(stmt.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}))
rows = (await session.execute(stmt)).all() rows = (await session.execute(stmt)).all()
# print("rows", rows) print("rows", rows)
# img_tag가 JSON 리터럴 null로 저장된 행(태깅 실패 잔재)은 SQL IS NOT NULL 필터를 print(rows)
# 통과하므로 파이썬 레벨에서 한 번 더 걸러낸다. print("image" , [{"image_url": row.img_url, "image_tag": row.img_tag} for row in rows])
null_tag_urls = [row.img_url for row in rows if row.img_tag is None] return [{"image_url": row.img_url, "image_tag": row.img_tag} for row in rows]
if null_tag_urls:
logger.warning(
f"[get_image_tags_by_task_id] img_tag가 null인 이미지 {len(null_tag_urls)}개 제외 "
f"- task_id: {task_id}, urls: {null_tag_urls}"
)
return [
{"image_url": row.img_url, "image_tag": row.img_tag}
for row in rows
if row.img_tag is not None
]

View File

@ -1,348 +0,0 @@
"""
Creative Assets Background Tasks
영상 생성 사전 준비(이미지 배정 + 자막 생성) 수행하는 백그라운드 태스크를 정의합니다.
"""
from sqlalchemy import select
from app.database.session import BackgroundSessionLocal
from app.home.models import Project, MarketingIntel
from app.utils.subtitles import SubtitleContentsGenerator
from app.utils import thumbnail_fitness
from app.utils.thumbnail_vision import pick_thumbnail_by_vision
from app.utils.creatomate import (
CreatomateService,
SCENE_TRACK,
SUBTITLE_TRACK,
KEYWORD_TRACK,
THUMBNAIL_SLOT_MARKER,
get_shared_client,
)
from app.utils.logger import get_logger
from app.video.services.video import get_image_tags_by_task_id
from app.utils.prompts.schemas.image import NARRATIVE_PHASE_LABEL
# 로거 설정
logger = get_logger("video")
async def generate_subtitle_background(
orientation: str,
task_id: str,
max_retries: int = 3,
) -> None:
"""자막 + 이미지 배정을 사전에 수행하는 백그라운드 태스크.
내부적으로 `generate_creative_assets_background` 호출한다.
"""
await generate_creative_assets_background(
orientation=orientation,
task_id=task_id,
max_retries=max_retries,
)
async def generate_creative_assets_background(
orientation: str,
task_id: str,
max_retries: int = 3,
) -> None:
"""이미지 배정 + 자막 생성을 사전에 수행하는 백그라운드 태스크.
실행 순서:
1. 템플릿 조회 (읽기 전용, 렌더 없음)
2. 이미지 매칭 get_image_tags_by_task_id template_matching_taged_image
MarketingIntel.image_match 저장
3. 자막 생성 배정된 이미지 컨텍스트(슬롯별 공간/단계 정보) pitching 리스트에 포함
MarketingIntel.subtitle 저장
"""
logger.info(f"[generate_creative_assets_background] START - task_id: {task_id}, orientation: {orientation}")
for attempt in range(1, max_retries + 1):
try:
# ── 프로젝트 / 마케팅 인텔 로드 ──────────────────────────────────
# 템플릿 선택에 project.industry가 필요하므로 서비스 생성보다 먼저 로드
async with BackgroundSessionLocal() as session:
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
creatomate_service = CreatomateService(orientation=orientation, industry=project.industry, project_id=project.id)
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
store_address = project.detail_region_info
customer_name = project.store_name
language = project.language or "Korean"
logger.info(
f"[generate_creative_assets_background] customer: {customer_name}, "
f"address: {store_address}, language: {language} - task_id: {task_id}"
)
# ── Step 1: 이미지 매칭 ─────────────────────────────────────────
# marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되었으므로 재필터링하지 않는다.
taged_image_list = await get_image_tags_by_task_id(task_id)
min_image_num = creatomate_service.counting_component(
template=template,
target_template_type="image",
)
duplicate = len(taged_image_list) < min_image_num
logger.info(
f"[generate_creative_assets_background] image matching — "
f"pool={len(taged_image_list)}, slots={min_image_num}, "
f"duplicate={duplicate} - task_id: {task_id}"
)
# 썸네일 슬롯(-9999)이 있는 템플릿이면 픽셀(헤더) 적합도 사전 계산.
# 헤더 바이트만 받아 크기를 파싱하므로 CPU 부담 없음 — 실패/타임아웃 시
# 빈 dict로 진행(태그 점수만 사용, 배정 자체는 막지 않음).
thumbnail_fitness_map: dict = {}
has_thumbnail_slot = any(
elem_type == "image" and name.endswith(THUMBNAIL_SLOT_MARKER)
for name, elem_type in creatomate_service.parse_template_component_name(
template["source"]["elements"]
).items()
)
if has_thumbnail_slot and taged_image_list:
client = await get_shared_client()
thumbnail_fitness_map = await thumbnail_fitness.score_pool_thumbnail_fitness(
taged_image_list, client
)
rejected = {
url: fit["reject"]
for url, fit in thumbnail_fitness_map.items()
if fit["reject"]
}
logger.info(
f"[generate_creative_assets_background] thumbnail fitness — "
f"scored={len(thumbnail_fitness_map)}/{len(taged_image_list)}, "
f"rejected={len(rejected)} {rejected if rejected else ''} - task_id: {task_id}"
)
# ── Step 1-b: 썸네일 최종 선택 (비전 LLM, Phase 2) ──────────────
# 규칙+픽셀로 압축한 top-3 후보를 비전 LLM이 실제로 보고 1컷 선택.
# 실패/후보 부족 시 thumbnail_choice가 비어 규칙 1위로 자동 폴백.
thumbnail_choice: dict = {}
if has_thumbnail_slot and taged_image_list:
candidates_by_slot = creatomate_service.rank_thumbnail_candidates(
template=template,
taged_image_list=taged_image_list,
thumbnail_fitness_map=thumbnail_fitness_map,
top_n=3,
)
for slot, candidates in candidates_by_slot.items():
chosen = await pick_thumbnail_by_vision(
candidates=candidates,
industry=project.industry,
business_name=customer_name,
)
if chosen is not None:
thumbnail_choice[slot] = chosen["image_url"]
logger.info(
f"[generate_creative_assets_background] thumbnail vision pick — "
f"{ {s: u.rsplit('/', 1)[-1] for s, u in thumbnail_choice.items()} } "
f"- task_id: {task_id}"
)
image_modifications, assigned_image_tags = creatomate_service.template_matching_taged_image(
template=template,
taged_image_list=taged_image_list,
music_url="", # 음악 URL은 영상 생성 시점에 결정 — 사전 단계에서는 빈 값
address=store_address,
duplicate=duplicate,
thumbnail_fitness_map=thumbnail_fitness_map,
thumbnail_choice=thumbnail_choice,
)
# audio-music은 영상 생성 시점에 덮어씌워지므로 image_match에서 제거
image_match = {k: v for k, v in image_modifications.items() if k != "audio-music"}
logger.info(f"[generate_creative_assets_background] image_match: {list(image_match.keys())} - task_id: {task_id}")
# image_match 저장
async with BackgroundSessionLocal() as session:
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
mi = marketing_result.scalar_one_or_none()
mi.image_match = image_match
await session.commit()
logger.info(f"[generate_creative_assets_background] image_match saved - task_id: {task_id}")
# ── Step 2: 자막 생성 (이미지 컨텍스트 포함) ──────────────────────
# 슬롯별 배정된 이미지의 공간/내러티브 정보를 pitching 레이블에 포함하여
# LLM이 "이 자막이 나올 때 화면에 보이는 이미지"를 인지하고 자막을 작성할 수 있게 함.
pitchings_raw = creatomate_service.extract_text_format_from_template(template)
pitchings_with_context = _build_pitching_list_with_image_context(
pitching_label_list=pitchings_raw,
assigned_image_tags=assigned_image_tags,
template=template,
creatomate_service=creatomate_service,
)
subtitle_generator = SubtitleContentsGenerator()
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
marketing_intelligence=marketing_intelligence.intel_result,
pitching_label_list=pitchings_with_context,
customer_name=customer_name,
detail_region_info=store_address,
language=language,
industry=project.industry,
)
pitching_output_list = generated_subtitles.pitching_results
# LLM이 pitching_tag에 " | 화면: ..." 컨텍스트 접미를 그대로 되돌려줄 수 있으므로 제거
subtitle_modifications = {
pitching_output.pitching_tag.split(IMAGE_CONTEXT_SEP)[0].strip(): pitching_output.pitching_data
for pitching_output in pitching_output_list
}
logger.info(f"[generate_creative_assets_background] subtitle_modifications: {subtitle_modifications}")
# subtitle 저장
async with BackgroundSessionLocal() as session:
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
mi = marketing_result.scalar_one_or_none()
mi.subtitle = subtitle_modifications
await session.commit()
logger.info(
f"[generate_creative_assets_background] DONE - task_id: {task_id} "
f"(attempt {attempt}/{max_retries})"
)
return
except Exception as e:
logger.error(
f"[generate_creative_assets_background] FAILED (attempt {attempt}/{max_retries}) "
f"- task_id: {task_id}, error: {e}",
exc_info=True,
)
if attempt < max_retries:
logger.info(
f"[generate_creative_assets_background] 재시도 중... "
f"({attempt + 1}/{max_retries}) - task_id: {task_id}"
)
logger.error(f"[generate_creative_assets_background] 모든 재시도 실패 - task_id: {task_id}")
# 이미지 컨텍스트 접미 구분자 — 생성(_context_for_label)과 제거(subtitle_modifications) 양쪽에서 사용
IMAGE_CONTEXT_SEP = " | "
def _merge_unique(tags: list[dict], key: str) -> list:
"""태그들의 key 리스트를 순서 유지하며 중복 제거해 병합합니다."""
return list(dict.fromkeys(item for tag in tags for item in tag.get(key, [])))
def _aggregate_tags_by_composition(
template: dict,
assigned_image_tags: dict[str, dict],
creatomate_service: CreatomateService,
) -> dict[str, dict]:
"""SCENE_TRACK 최상위 컴포지션별로 배정된 이미지 태그를 집계합니다.
컴포지션 하위 leaf image가 여러 개면(다중 이미지 ) space_type/subject를
순서 유지 중복 제거로 병합합니다. 배정된 태그가 없는 컴포지션(CTA )
결과에 포함하지 않습니다.
"""
comp_tags: dict[str, dict] = {}
for elem in template["source"]["elements"]:
if elem.get("track") != SCENE_TRACK or not elem.get("name"):
continue
name_to_type = creatomate_service.parse_template_component_name([elem])
leaf_tags = [
assigned_image_tags[name]
for name, elem_type in name_to_type.items()
if elem_type == "image" and name in assigned_image_tags
]
if not leaf_tags:
continue
comp_tags[elem["name"]] = {
"space_type": _merge_unique(leaf_tags, "space_type"),
"subject": _merge_unique(leaf_tags, "subject"),
}
return comp_tags
def _context_for_label(
text_range: tuple[float, float],
scene_ranges: list[tuple[str, float, float]],
comp_tags: dict[str, dict],
) -> str:
"""텍스트 재생 구간과 시간 겹침이 최대인 씬의 이미지 컨텍스트 접미를 만듭니다.
태그가 배정된 컴포지션 겹침이 최대인 씬을 선택하며(동률 이른 시작 우선),
겹치는 씬이 없으면 문자열을 반환합니다.
"""
text_start, text_end = text_range
candidates = [
(min(text_end, comp_end) - max(text_start, comp_start), -comp_start, comp_name)
for comp_name, comp_start, comp_end in scene_ranges
if comp_name in comp_tags
]
candidates = [c for c in candidates if c[0] > 0]
if not candidates:
return ""
best_comp = max(candidates)[2]
tag = comp_tags[best_comp]
space_types = tag.get("space_type", [])
subjects = tag.get("subject", [])
space_str = "/".join(space_types[:2]) if space_types else "?"
subject_str = subjects[0] if subjects else "?"
# 컴포지션명 형식: {space_type}-{subject}-{camera}-{motion}-{narrative_phase}
comp_tokens = best_comp.split("-")
phase = comp_tokens[4] if len(comp_tokens) >= 5 else ""
phase_str = NARRATIVE_PHASE_LABEL.get(phase, phase)
return f"{IMAGE_CONTEXT_SEP}화면: {space_str} / {subject_str} / {phase_str} 단계"
def _build_pitching_list_with_image_context(
pitching_label_list: list[str],
assigned_image_tags: dict[str, dict],
template: dict,
creatomate_service: CreatomateService,
) -> list[str]:
"""pitching 레이블 리스트에 배정 이미지 컨텍스트를 포함하여 반환합니다.
텍스트 레이어(SUBTITLE/KEYWORD_TRACK) 이미지 컴포지션(SCENE_TRACK)
시간 범위를 계산해, 텍스트가 재생되는 동안 화면에 가장 오래 보이는
(시간 겹침이 최대인) 컴포지션의 이미지 태그를 컨텍스트로 붙입니다.
narrative_phase 토큰 매칭과 달리 phase 이름이 어긋나거나(자막 outro vs
이미지 accent) 같은 phase에 씬이 여러 개여도 올바른 짝을 찾습니다.
겹치는 씬이 없거나 해당 씬에 배정된 이미지가 없으면(CTA ) 컨텍스트를
생략합니다. 렌더 extend_template_duration은 트랙 균등 배율이므로
원본 템플릿 기준 겹침 관계는 스케일 후에도 유지됩니다.
출력 예시:
"subtitle-welcome-emotion_cue-empathic-001 | 화면: lobby / furniture / 진입(welcome) 단계"
"""
comp_tags = _aggregate_tags_by_composition(template, assigned_image_tags, creatomate_service)
if not comp_tags:
return pitching_label_list
scene_ranges = creatomate_service.calc_track_time_ranges(template, SCENE_TRACK)
text_ranges: dict[str, tuple[float, float]] = {}
for track in (SUBTITLE_TRACK, KEYWORD_TRACK):
for name, start, end in creatomate_service.calc_track_time_ranges(template, track):
text_ranges[name] = (start, end)
result = []
for label in pitching_label_list:
text_range = text_ranges.get(label)
context_str = _context_for_label(text_range, scene_ranges, comp_tags) if text_range else ""
result.append(label + context_str)
return result

View File

@ -1,12 +0,0 @@
-- ============================================================
-- Migration: project 테이블에 biz_type 컬럼 추가
-- Date: 2026-06-17
-- Description: 업종 구분 컬럼명 biz_type
-- ============================================================
ALTER TABLE project
ADD COLUMN biz_type VARCHAR(50) NULL DEFAULT 'place'
COMMENT '업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)' AFTER id;
ALTER TABLE project CHANGE biz_type industry VARCHAR(50) DEFAULT ''
COMMENT '업종 분류' AFTER id;

View File

@ -1,12 +0,0 @@
-- ============================================================
-- Migration: lyric 테이블에 genre 컬럼 추가
-- Date: 2026-07-02
-- Description: 가사 생성 시점에 확정된 음악 장르 저장
-- - 수동 선택: 사용자가 고른 장르
-- - 자동 선택: GPT가 가사 무드에 맞춰 추천한 장르
-- 장르-BPM 불일치로 인한 곡 길이 편차 문제 해결을 위해 도입
-- ============================================================
ALTER TABLE lyric
ADD COLUMN genre VARCHAR(20) NULL
COMMENT '음악 장르 (kpop, pop, ballad, hip-hop, rnb, edm, jazz, rock). 수동 선택 시 요청값, 자동 선택 시 GPT 추천값' AFTER language;

View File

@ -1,12 +0,0 @@
-- ============================================================
-- Migration: marketing 테이블에 image_match 컬럼 추가
-- Date: 2026-07-03
-- Description: 이미지 슬롯 배정 결과물 저장
-- - lyric 생성 사전 단계(get_image_tags_by_task_id → template_matching_taged_image)에서
-- 산출된 슬롯별 이미지 배정 결과를 저장
-- - 영상 생성 시점에는 저장된 결과를 읽어 음악 URL/주소만 보완하여 재사용
-- ============================================================
ALTER TABLE marketing
ADD COLUMN image_match JSON NULL
COMMENT '이미지 슬롯 배정 결과물' AFTER subtitle;