Compare commits
26 Commits
feature-su
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
cbc3f47b54 | |
|
|
6090d500f9 | |
|
|
788e76ef76 | |
|
|
a253c5dd3a | |
|
|
153484f2bd | |
|
|
7ec9e9749d | |
|
|
236c7916a7 | |
|
|
593b6d9922 | |
|
|
18ba089110 | |
|
|
64b9959000 | |
|
|
3af57500b2 | |
|
|
2dadb843c8 | |
|
|
e6764a85bd | |
|
|
2107d5adf1 | |
|
|
e53084ed17 | |
|
|
76485ebbcc | |
|
|
b8be2a355a | |
|
|
4d8462651a | |
|
|
46449a7a3f | |
|
|
04d8614919 | |
|
|
8415e1c3a7 | |
|
|
b908408b4a | |
|
|
009b1a5d09 | |
|
|
d1fe7fcd5f | |
|
|
8d021c8555 | |
|
|
825ab1fd89 |
|
|
@ -139,11 +139,18 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logger.error(traceback.format_exc())
|
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
|
||||||
logger.error(
|
if getattr(e, "status_code", 500) < 500:
|
||||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
logger.warning(
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
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(
|
||||||
|
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||||
|
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
total_time = time.perf_counter() - start_time
|
total_time = time.perf_counter() - start_time
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ 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 Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
|
@ -39,6 +41,8 @@ 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")
|
||||||
|
|
@ -48,18 +52,19 @@ 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**: 도로명 주소
|
||||||
""",
|
""",
|
||||||
|
|
@ -72,7 +77,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,
|
||||||
|
|
@ -91,6 +96,44 @@ 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="네이버 지도 크롤링",
|
||||||
|
|
@ -206,12 +249,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 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)"
|
f"[crawling] Step 1 완료 - 업체사진 {len(scraper.owner_images or [])}개, "
|
||||||
|
f"보충사진 {len(scraper.extra_photo_urls or [])}개 ({step1_elapsed:.1f}ms)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ========== Step 2: 정보 가공 ==========
|
# ========== Step 2: 정보 가공 (industry 선행 계산) ==========
|
||||||
step2_start = time.perf_counter()
|
step2_start = time.perf_counter()
|
||||||
logger.info("[crawling] Step 2: 정보 가공 시작...")
|
logger.info("[crawling] Step 2: 정보 가공 시작...")
|
||||||
|
|
||||||
|
|
@ -222,38 +265,109 @@ 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} ({step2_elapsed:.1f}ms)"
|
f"[crawling] Step 2 완료 - {customer_name}, {region}, "
|
||||||
|
f"category={category!r}, industry={industry!r} ({step2_elapsed:.1f}ms)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ========== Step 3: ChatGPT 마케팅 분석 ==========
|
# ========== Step 3: 이미지 마케팅 적합성 필터링 ==========
|
||||||
|
# 업체 사진이 SUPPLEMENT_THRESHOLD(30장) 이상이면 보충이 불필요하므로
|
||||||
|
# 방문자 사진 필터링(Gemini 호출) 자체를 건너뛴다.
|
||||||
step3_start = time.perf_counter()
|
step3_start = time.perf_counter()
|
||||||
logger.info("[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
|
owner_images = scraper.owner_images or []
|
||||||
|
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 3-1: ChatGPT 서비스 초기화 및 입력 데이터 구성
|
# Step 4-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 3-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환
|
# Step 4-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 3-3: 분석 결과 DB 저장
|
# Step 4-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장)
|
||||||
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(),
|
||||||
|
|
@ -264,26 +378,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}")
|
||||||
|
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)"
|
f"[crawling] Step 4 완료 - 마케팅 분석 성공 ({step4_elapsed:.1f}ms)"
|
||||||
)
|
)
|
||||||
|
|
||||||
except ChatGPTResponseError as e:
|
except ChatGPTResponseError as e:
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[crawling] Step 3 FAILED - ChatGPT Error: status={e.status}, "
|
f"[crawling] Step 4 FAILED - ChatGPT Error: status={e.status}, "
|
||||||
f"code={e.error_code}, message={e.error_message} ({step3_elapsed:.1f}ms)"
|
f"code={e.error_code}, message={e.error_message} ({step4_elapsed:.1f}ms)"
|
||||||
)
|
)
|
||||||
marketing_analysis = None
|
marketing_analysis = None
|
||||||
gpt_status = "failed"
|
gpt_status = "failed"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)"
|
f"[crawling] Step 4 FAILED - GPT 마케팅 분석 중 오류: {e} ({step4_elapsed:.1f}ms)"
|
||||||
)
|
)
|
||||||
logger.exception("[crawling] Step 3 상세 오류:")
|
logger.exception("[crawling] Step 4 상세 오류:")
|
||||||
marketing_analysis = None
|
marketing_analysis = None
|
||||||
gpt_status = "failed"
|
gpt_status = "failed"
|
||||||
else:
|
else:
|
||||||
|
|
@ -300,7 +414,9 @@ 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 (GPT 분석): {step3_elapsed:.1f}ms")
|
logger.info(f"[crawling] - Step 3 (이미지 필터링): {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",
|
||||||
|
|
@ -308,7 +424,8 @@ 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 "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -322,6 +439,7 @@ 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)
|
||||||
|
|
@ -346,10 +464,17 @@ 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
|
||||||
|
|
@ -386,6 +511,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -549,6 +675,10 @@ 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)
|
||||||
|
|
@ -750,9 +880,21 @@ 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, clear_old_tags=True)
|
await tagging_images(image_urls, industry=industry, 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}, "
|
||||||
|
|
@ -772,6 +914,7 @@ 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. 조회
|
||||||
|
|
@ -797,12 +940,17 @@ 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])
|
tag_datas = await autotag_images([img.img_url for img in null_imts], industry=industry)
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,13 @@ 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,
|
||||||
|
|
@ -307,6 +314,12 @@ 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,
|
||||||
|
|
@ -357,8 +370,10 @@ class ImageTag(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
img_tag: Mapped[dict[str, Any]] = mapped_column(
|
img_tag: Mapped[dict[str, Any]] = mapped_column(
|
||||||
JSON,
|
# none_as_null=True: ORM에서 None 대입 시 JSON 리터럴 null이 아닌 SQL NULL로 저장.
|
||||||
|
# 미지정 시 JSON null이 저장되어 `img_tag.is_not(None)` 필터를 통과하는 버그가 있었음.
|
||||||
|
JSON(none_as_null=True),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
default=False,
|
default=None,
|
||||||
comment="태그 JSON",
|
comment="태그 JSON",
|
||||||
)
|
)
|
||||||
|
|
@ -78,6 +78,7 @@ 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):
|
||||||
|
|
@ -97,6 +98,12 @@ 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):
|
||||||
"""크롤링 응답 스키마"""
|
"""크롤링 응답 스키마"""
|
||||||
|
|
||||||
|
|
@ -104,7 +111,7 @@ class CrawlingResponse(BaseModel):
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"],
|
"image_list": [{"preview": "https://example.com/image1.jpg", "original": "https://example.com/image1.jpg"}],
|
||||||
"image_count": 2,
|
"image_count": 2,
|
||||||
"processed_info": {
|
"processed_info": {
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
|
|
@ -233,7 +240,7 @@ class CrawlingResponse(BaseModel):
|
||||||
default="completed",
|
default="completed",
|
||||||
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
|
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
|
||||||
)
|
)
|
||||||
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
image_list: Optional[list[ImageListItem]] = Field(None, description="이미지 목록 (preview/original 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)"
|
||||||
|
|
@ -242,6 +249,7 @@ 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):
|
||||||
|
|
@ -252,12 +260,14 @@ 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):
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class NaverSearchClient:
|
||||||
display: int = 5,
|
display: int = 5,
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
숙박/펜션 검색
|
장소 검색 (숙박, 음식점 등)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: 검색어
|
query: 검색어
|
||||||
|
|
@ -41,8 +41,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ from app.lyric.schemas.lyric import (
|
||||||
LyricStatusResponse,
|
LyricStatusResponse,
|
||||||
SubtitleStatusResponse,
|
SubtitleStatusResponse,
|
||||||
)
|
)
|
||||||
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
|
from app.lyric.worker.lyric_task import generate_lyric_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
|
||||||
|
|
@ -160,6 +161,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -269,15 +271,21 @@ 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" : """
|
|
||||||
8–12 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,
|
||||||
|
|
@ -285,9 +293,14 @@ 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,
|
||||||
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
"genre": request_body.genre or "", # 비어있으면 GPT가 가사 무드에 맞춰 장르를 직접 추천
|
||||||
|
"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)")
|
||||||
|
|
||||||
|
|
@ -313,7 +326,8 @@ 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()
|
||||||
|
|
@ -327,7 +341,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 = lyric_prompt.build_prompt(lyric_input_data)
|
estimated_prompt = selected_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,
|
||||||
|
|
@ -335,6 +349,7 @@ 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()
|
||||||
|
|
@ -358,7 +373,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=lyric_prompt,
|
prompt=selected_lyric_prompt,
|
||||||
lyric_input_data=lyric_input_data,
|
lyric_input_data=lyric_input_data,
|
||||||
lyric_id=lyric.id,
|
lyric_id=lyric.id,
|
||||||
)
|
)
|
||||||
|
|
@ -570,19 +585,29 @@ async def get_subtitle_status(
|
||||||
detail=f"task_id '{task_id}'에 해당하는 마케팅 인텔리전스를 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 마케팅 인텔리전스를 찾을 수 없습니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if intel.subtitle:
|
# 자막과 이미지 배정 모두 완료돼야 영상 생성 가능
|
||||||
logger.info(f"[get_subtitle_status] completed - task_id: {task_id}")
|
subtitle_done = bool(intel.subtitle)
|
||||||
|
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="자막 생성 및 이미지 배정이 완료되었습니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"[get_subtitle_status] pending - task_id: {task_id}")
|
pending_parts = []
|
||||||
|
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="자막 생성이 진행 중입니다. 잠시 후 다시 확인해주세요.",
|
message=f"{pending_msg} 처리가 진행 중입니다. 잠시 후 다시 확인해주세요.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,13 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -70,13 +70,19 @@ 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):
|
||||||
|
|
@ -199,6 +205,10 @@ 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="생성 일시")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,30 +4,24 @@ 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 테이블의 상태를 업데이트합니다.
|
||||||
|
|
||||||
|
|
@ -36,6 +30,7 @@ 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: 업데이트 성공 여부
|
||||||
|
|
@ -61,6 +56,8 @@ 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
|
||||||
|
|
@ -117,14 +114,18 @@ 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(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
|
logger.info(
|
||||||
|
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)
|
await _update_lyric_status(task_id, "completed", result, lyric_id, genre=confirmed_genre)
|
||||||
|
|
||||||
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)")
|
||||||
|
|
@ -155,72 +156,3 @@ 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}")
|
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,10 @@ 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ class YouTubeUploader(BaseSocialUploader):
|
||||||
body = {
|
body = {
|
||||||
"snippet": {
|
"snippet": {
|
||||||
"title": metadata.title,
|
"title": metadata.title,
|
||||||
"description": metadata.description or "",
|
"description": self._sanitize_description(metadata.description),
|
||||||
"tags": metadata.tags or [],
|
"tags": metadata.tags or [],
|
||||||
"categoryId": self._get_category_id(metadata),
|
"categoryId": self._get_category_id(metadata),
|
||||||
},
|
},
|
||||||
|
|
@ -380,6 +380,12 @@ 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 형식으로 변환
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,33 @@ 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}",
|
||||||
|
|
@ -72,7 +99,7 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
|
||||||
```
|
```
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 생성되는 노래는 약 1분 이내 길이입니다.
|
- 생성되는 노래는 약 40초 내외 길이입니다.
|
||||||
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
||||||
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
||||||
""",
|
""",
|
||||||
|
|
@ -413,12 +440,14 @@ 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:
|
||||||
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
|
# 생성된 클립(보통 2개) 중 목표 길이(1분)에 가장 가까운 클립 선택 (동일하면 첫 번째)
|
||||||
first_clip = clips_data[0]
|
first_clip = _select_clip_by_duration(clips_data)
|
||||||
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] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}"
|
f"[get_song_status] Selected clip by duration - id: {first_clip.get('id')}, "
|
||||||
|
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:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,15 @@
|
||||||
|
# 특별시/광역시/세종시: 하위 행정구역이 구(gu)이므로 별도 처리
|
||||||
|
METRO_SIDOS: dict[str, str] = {
|
||||||
|
"서울특별시": "서울시",
|
||||||
|
"부산광역시": "부산시",
|
||||||
|
"대구광역시": "대구시",
|
||||||
|
"인천광역시": "인천시",
|
||||||
|
"광주광역시": "광주시",
|
||||||
|
"대전광역시": "대전시",
|
||||||
|
"울산광역시": "울산시",
|
||||||
|
"세종특별시": "세종시",
|
||||||
|
}
|
||||||
|
|
||||||
SIDO_CITIES: dict[str, list[str]] = {
|
SIDO_CITIES: dict[str, list[str]] = {
|
||||||
"서울특별시": ["서울시"],
|
"서울특별시": ["서울시"],
|
||||||
"부산광역시": ["부산시"],
|
"부산광역시": ["부산시"],
|
||||||
|
|
@ -61,6 +73,12 @@ 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]
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommen
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
async def autotag_image(image_url : str) -> list[str]: #tag_list
|
async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_list
|
||||||
chatgpt = ChatgptService(model_type="gemini")
|
chatgpt = ChatgptService(model_type="gpt")
|
||||||
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),
|
||||||
|
|
@ -19,10 +20,11 @@ async def autotag_image(image_url : str) -> list[str]: #tag_list
|
||||||
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]) -> list[dict]: #tag_list
|
async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list
|
||||||
chatgpt = ChatgptService(model_type="gemini")
|
chatgpt = ChatgptService(model_type="gpt")
|
||||||
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),
|
||||||
|
|
@ -31,10 +33,10 @@ async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_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(
|
||||||
|
|
@ -44,5 +46,5 @@ async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_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
|
||||||
|
|
@ -30,6 +30,7 @@ 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
|
||||||
|
|
@ -224,28 +225,120 @@ autotext_template_h_1 = {
|
||||||
"stroke_color": "#333333",
|
"stroke_color": "#333333",
|
||||||
"stroke_width": "0.2 vmin"
|
"stroke_width": "0.2 vmin"
|
||||||
}
|
}
|
||||||
DVST0001 = "75161273-0422-4771-adeb-816bd7263fb0"
|
DVST0001 = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
|
||||||
DVST0002 = "c68cf750-bc40-485a-a2c5-3f9fe301e386"
|
DVRT0001 = "3c4b74c4-82d7-4742-9b05-4b999fef4cbd"
|
||||||
DVST0003 = "e1fb5b00-1f02-4f63-99fa-7524b433ba47"
|
DVCF0001 = "ebb532ec-e49a-4a7e-b0e2-a8132f1bf1f7"
|
||||||
|
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"
|
|
||||||
DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d"
|
HST_LIST = [DHST0001]
|
||||||
DVST0001T = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
|
VST_LIST = [DVST0001,DVRT0001,DVCF0001,DVAT0001,DVSL0001,DVCL0001,DVFT0001,DVAC0001]
|
||||||
HST_LIST = [DHST0001,DHST0002,DHST0003]
|
|
||||||
VST_LIST = [DVST0001,DVST0002,DVST0003, DVST0001T]
|
# industry → 세로형 템플릿 매핑 (신규 세로형 템플릿 추가 시 여기에만 등록)
|
||||||
|
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
|
||||||
|
|
||||||
def select_template(orientation:OrientationType):
|
# 썸네일 컴포지션의 이미지 슬롯명 접미사 (8개 업종 템플릿 전수 확인, 예:
|
||||||
|
# "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":
|
||||||
return DHST0001
|
template_id = DHST0001
|
||||||
elif orientation == "vertical":
|
elif orientation == "vertical":
|
||||||
return DVST0001T
|
mapped = VERTICAL_INDUSTRY_TEMPLATE_MAP.get(industry)
|
||||||
|
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 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||||
|
|
@ -293,21 +386,25 @@ 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")
|
||||||
target_duration: 목표 영상 길이 (초)
|
industry: 업종 분류 (stay|restaurant|cafe|attraction 등). 세로형 템플릿 선택에 사용되며,
|
||||||
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에 따른 템플릿 설정 가져오기
|
# orientation·industry에 따른 템플릿 설정 가져오기
|
||||||
self.template_id = select_template(orientation)
|
self.template_id = select_template(orientation, industry=industry, project_id=project_id)
|
||||||
self.headers = {
|
self.headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
|
@ -443,52 +540,309 @@ 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 _, (_, template_type) in enumerate(template_component_data.items()):
|
for name, template_type in template_component_data.items():
|
||||||
if template_type == target_template_type:
|
if template_type != target_template_type:
|
||||||
count += 1
|
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
|
||||||
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_name" : str , "image_tag" : dict}]
|
taged_image_list: list, # [{"image_url": str, "image_tag": dict}] — 이미 마케팅 적합성 필터를 통과한 이미지만 전달할 것
|
||||||
music_url: str,
|
music_url: str,
|
||||||
address : str,
|
address: str,
|
||||||
duplicate : bool = False
|
duplicate: bool = False,
|
||||||
) -> list:
|
thumbnail_fitness_map: dict | None = None, # {image_url: {"score", "reject"}} — 썸네일 슬롯 픽셀 적합도
|
||||||
|
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)
|
||||||
|
|
||||||
taged_image_list = [img for img in taged_image_list if img.get("image_tag") is not None]
|
pool = taged_image_list
|
||||||
modifications = {}
|
modifications: dict = {}
|
||||||
|
assigned: dict = {} # {슬롯명: image_tag} — 자막 컨텍스트용
|
||||||
|
|
||||||
for slot_idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
|
# 이미지 슬롯과 텍스트 슬롯 분리
|
||||||
match template_type:
|
# 고정 자산(이름이 '-fixed'로 끝나는 로고 등) 및 5토큰 명명 규칙을 따르지
|
||||||
case "image":
|
# 않는 image 요소는 콘텐츠 슬롯이 아니므로 배정 대상에서 제외 — 그렇지
|
||||||
image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name)
|
# 않으면 파싱 실패로 0점 처리되어 "가장 까다로운 슬롯"으로 취급되고
|
||||||
maximum_idx = image_score_list.index(max(image_score_list))
|
# 무작위 이미지로 덮어써진다.
|
||||||
if duplicate:
|
image_slots = [
|
||||||
selected = taged_image_list[maximum_idx]
|
name for name, t in template_component_data.items()
|
||||||
else:
|
if t == "image" and not is_fixed_slot_name(name) and self.parse_slot_name_to_tag(name) is not None
|
||||||
selected = taged_image_list.pop(maximum_idx)
|
]
|
||||||
image_name = selected["image_url"]
|
text_slots = [(name, t) for name, t in template_component_data.items() if t == "text"]
|
||||||
modifications[template_component_name] =image_name
|
|
||||||
pass
|
# ── 썸네일 슬롯(-9999) 우선 배정 ─────────────────────────────────
|
||||||
case "text":
|
# 썸네일은 영상에서 가장 노출이 큰 표면이므로 씬 슬롯과 다르게 취급한다:
|
||||||
if "address_input" in template_component_name:
|
# 1) 씬 슬롯이 pool에서 좋은 컷을 pop해 가기 전에 먼저 배정 (같은 태그
|
||||||
modifications[template_component_name] = address
|
# 계열 씬 슬롯이 여러 개면 썸네일 차례에 적합 컷이 소진되는 문제 방지)
|
||||||
|
# 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:
|
||||||
|
sel_idx, sel_source = max(range(len(scores)), key=lambda i: scores[i]), "결정론"
|
||||||
|
selected = pool[sel_idx] if duplicate else pool.pop(sel_idx)
|
||||||
|
modifications[slot] = selected["image_url"]
|
||||||
|
assigned[slot] = selected["image_tag"]
|
||||||
|
logger.info(
|
||||||
|
f"[template_matching_taged_image] 썸네일 배정({sel_source}) — slot: {slot}, "
|
||||||
|
f"score: {scores[sel_idx]:.3f}, url: {selected['image_url']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
return modifications, assigned
|
||||||
|
|
||||||
|
# 점수 계산 상수 (모듈 레벨로 빼면 더 좋지만 기존 코드 스타일 따라 클래스 내부에 위치)
|
||||||
|
_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)
|
||||||
image_score_list = [0] * len(image_tag_list)
|
if slot_tag_dict is None:
|
||||||
|
# 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":
|
||||||
|
|
@ -497,41 +851,64 @@ class CreatomateService:
|
||||||
|
|
||||||
match slot_tag_cate:
|
match slot_tag_cate:
|
||||||
case "space_type":
|
case "space_type":
|
||||||
weight = 2
|
weight = 2.0
|
||||||
case "subject" :
|
case "subject":
|
||||||
weight = 2
|
weight = 3.0
|
||||||
case "camera":
|
case "camera":
|
||||||
weight = 1
|
weight = 1.0
|
||||||
case "motion_recommended" :
|
case "motion_recommended":
|
||||||
weight = 0.5
|
weight = 0.5
|
||||||
case _:
|
case _:
|
||||||
raise
|
raise ValueError(f"[calculate_image_slot_score_multi] unknown slot tag category: {slot_tag_cate}")
|
||||||
|
|
||||||
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!
|
||||||
image_score_list[idx] += weight
|
base_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):
|
||||||
image_narrative_score = image_tag["narrative_preference"][slot_tag_narrative]
|
raw_narr = image_tag.get("narrative_preference", {}).get(slot_tag_narrative, 0.0)
|
||||||
image_score_list[idx] = image_score_list[idx] * image_narrative_score
|
clamped_narr = min(self._NARR_MAX, max(self._NARR_MIN, float(raw_narr)))
|
||||||
|
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]:
|
def parse_slot_name_to_tag(self, slot_name: str) -> dict[str, StrEnum] | None:
|
||||||
tag_list = slot_name.split("-")
|
"""슬롯 이름을 파싱하여 태그 딕셔너리를 반환합니다.
|
||||||
space_type = SpaceType(tag_list[0])
|
|
||||||
subject = Subject(tag_list[1])
|
슬롯 이름 형식: {space_type}-{subject}-{camera}-{motion}-{narrative}
|
||||||
camera = Camera(tag_list[2])
|
파싱 실패 시 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리).
|
||||||
motion = MotionRecommended(tag_list[3])
|
"""
|
||||||
narrative = NarrativePhase(tag_list[4])
|
try:
|
||||||
tag_dict = {
|
tag_list = slot_name.split("-")
|
||||||
"space_type" : space_type,
|
if len(tag_list) < 5:
|
||||||
"subject" : subject,
|
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 토큰 부족 ({len(tag_list)}개): '{slot_name}' — 슬롯 skip")
|
||||||
"camera" : camera,
|
return None
|
||||||
"motion_recommended" : motion,
|
space_type = SpaceType(tag_list[0])
|
||||||
"narrative_preference" : narrative,
|
subject = Subject(tag_list[1])
|
||||||
}
|
camera = Camera(tag_list[2])
|
||||||
return tag_dict
|
# 모션 동의어(zoom_in, pan_left 등)는 표준 enum 값으로 정규화 후 변환
|
||||||
|
motion = MOTION_TOKEN_NORMALIZATION.get(tag_list[3]) or MotionRecommended(tag_list[3])
|
||||||
|
narrative = NarrativePhase(tag_list[4])
|
||||||
|
tag_dict = {
|
||||||
|
"space_type": space_type,
|
||||||
|
"subject": subject,
|
||||||
|
"camera": camera,
|
||||||
|
"motion_recommended": motion,
|
||||||
|
"narrative_preference": narrative,
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
|
@ -562,20 +939,26 @@ 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[element["name"]]
|
element["source"] = modification.get(element["name"], element.get("source"))
|
||||||
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["text"] = modification.get(element["name"], element.get("text", ""))
|
||||||
case "composition":
|
case "composition":
|
||||||
for minor in element["elements"]:
|
for minor in element["elements"]:
|
||||||
recursive_modify(minor)
|
recursive_modify(minor)
|
||||||
|
|
@ -679,6 +1062,8 @@ 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):
|
||||||
|
|
@ -755,6 +1140,20 @@ 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
|
||||||
|
|
@ -768,16 +1167,7 @@ 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"]
|
track_maximum_duration[elem["track"]] += elem["duration"] - self._entrance_transition_offset(elem)
|
||||||
|
|
||||||
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"])
|
||||||
|
|
||||||
|
|
@ -789,6 +1179,73 @@ 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 # 늘린것보단 짧게
|
||||||
|
|
@ -799,21 +1256,10 @@ class CreatomateService:
|
||||||
|
|
||||||
for elem in new_template["source"]["elements"]:
|
for elem in new_template["source"]["elements"]:
|
||||||
try:
|
try:
|
||||||
# if elem["type"] == "audio":
|
if elem["track"] == AUDIO_TRACK : # audio track(최상위 음악 트랙)은 패스
|
||||||
# continue
|
|
||||||
if elem["track"] == AUDIO_TRACK : # audio track은 패스
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if "time" in elem:
|
self._scale_element(elem, extend_rate)
|
||||||
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}"
|
||||||
|
|
@ -850,12 +1296,38 @@ 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":
|
if elem["type"] == "text" and not is_fixed_slot_name(elem["name"]):
|
||||||
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:
|
||||||
|
|
@ -869,21 +1341,41 @@ 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, brand_concept : str, category_definition : str, target_keywords : list[str]):
|
def make_thumbnail_modification(self, brand_name : str, region : str, category_definition : str, target_keywords : list[str], detail_region_info : 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-brand-wordmark" : brand_name,
|
"thumb-headline-brand_name-factual" : brand_name,
|
||||||
"thumb-subheadline-selling_point" : f"{brand_name} · {normalize_location(region)}",
|
"thumb-subheadline-local_info-factual" : region_display,
|
||||||
"thumb-headline-hook_claim-aspirational" : brand_concept,
|
|
||||||
"thumb-badge-category" : category_definition,
|
"thumb-badge-category" : category_definition,
|
||||||
}
|
}
|
||||||
return mod_dict
|
return mod_dict
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""크롤링 단계 이미지 마케팅 적합성 필터.
|
||||||
|
|
||||||
|
마케팅 적합성(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]
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -19,23 +20,76 @@ class NvMapPwScraper():
|
||||||
_win_width = 1280
|
_win_width = 1280
|
||||||
_win_height = 720
|
_win_height = 720
|
||||||
_max_retry = 3
|
_max_retry = 3
|
||||||
_timeout = 60 # place id timeout threshold seconds
|
_timeout = 30 # 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'] = {
|
context_builder_dict['viewport'] = dict(profile['viewport'])
|
||||||
'width' : cls._win_width,
|
context_builder_dict['screen'] = dict(profile['viewport'])
|
||||||
'height' : cls._win_height
|
context_builder_dict['user_agent'] = profile['user_agent']
|
||||||
}
|
|
||||||
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'
|
||||||
|
|
||||||
|
|
@ -51,6 +105,129 @@ class NvMapPwScraper():
|
||||||
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")
|
||||||
|
|
@ -63,35 +240,7 @@ class NvMapPwScraper():
|
||||||
await self.page.close()
|
await self.page.close()
|
||||||
|
|
||||||
async def create_page(self):
|
async def create_page(self):
|
||||||
self.page = await self._context.new_page()
|
self.page = await self._new_stealth_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):
|
||||||
|
|
@ -184,33 +333,114 @@ patchedGetter.toString();''')
|
||||||
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}"
|
||||||
|
|
||||||
try:
|
# 1순위: allSearch API 응답 캡처 (iframe 렌더링 대기 불필요, 업체명 단독 검색도 지원)
|
||||||
await self.goto_url(url, wait_until="networkidle", timeout=self._timeout * 1000)
|
candidates = await self._capture_allsearch(url)
|
||||||
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 = max(
|
best = self._select_best_candidate(candidates, title, address)
|
||||||
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']}' (score={best_score:.2f}) {best['place_url']}"
|
f"[AUTO-SELECT] '{title}' → '{best['title']}' "
|
||||||
|
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:
|
||||||
|
|
@ -238,16 +468,27 @@ patchedGetter.toString();''')
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,13 @@ 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}) {
|
||||||
|
|
@ -50,8 +57,37 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
|
|
@ -69,9 +105,13 @@ query getAccommodation($id: String!, $deviceType: 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[str] | None = None
|
self.image_link_list: list[dict] | None = None # [{"preview": str, "original": str}]
|
||||||
|
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()
|
||||||
|
|
@ -79,6 +119,98 @@ query getAccommodation($id: String!, $deviceType: 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+)"
|
||||||
|
|
@ -95,29 +227,221 @@ query getAccommodation($id: String!, $deviceType: 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)
|
||||||
match = re.search(r"place\.naver\.com/[a-zA-Z]+/(\d+)", self.url)
|
type_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)
|
|
||||||
self.rawdata = data
|
# ── 빠른 경로: 직접 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)
|
fac_data = await self._get_facility_string(place_id)
|
||||||
|
self.scrap_type = "GraphQL-Browser"
|
||||||
|
|
||||||
|
self.rawdata = data
|
||||||
# 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
|
||||||
self.image_link_list = [
|
business = data["data"]["business"]
|
||||||
nv_image["origin"]
|
|
||||||
for nv_image in data["data"]["business"]["images"]["images"]
|
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup).
|
||||||
]
|
# 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.scrap_type = "GraphQL"
|
self.voted_keyword_stats = stats_data
|
||||||
|
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를 호출하여 숙소 정보를 가져옵니다.
|
||||||
|
|
||||||
|
|
@ -146,13 +470,14 @@ query getAccommodation($id: String!, $deviceType: 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}"
|
||||||
|
|
@ -161,13 +486,43 @@ query getAccommodation($id: String!, $deviceType: 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
|
||||||
|
|
@ -175,16 +530,17 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
Returns:
|
Returns:
|
||||||
편의시설 정보 문자열 또는 None
|
편의시설 정보 문자열 또는 None
|
||||||
"""
|
"""
|
||||||
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
|
place_types = ["place", "accommodation", "restaurant"]
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url, headers=self._get_request_headers()) as response:
|
for place_type in place_types:
|
||||||
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
|
url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home"
|
||||||
c_elem = soup.find("span", "place_blind", string="편의")
|
async with session.get(url, headers=self._get_request_headers()) as response:
|
||||||
if c_elem:
|
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
|
||||||
facilities = c_elem.parent.parent.find("div").string
|
c_elem = soup.find("span", "place_blind", string="편의")
|
||||||
return facilities
|
if c_elem:
|
||||||
return None
|
return c_elem.parent.parent.find("div").string
|
||||||
|
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}")
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ValidationError
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
|
@ -131,23 +131,35 @@ class ChatgptService:
|
||||||
})
|
})
|
||||||
last_error = None
|
last_error = None
|
||||||
for attempt in range(self.max_retries + 1):
|
for attempt in range(self.max_retries + 1):
|
||||||
response = await self.client.beta.chat.completions.parse(
|
try:
|
||||||
model=model,
|
response = await self.client.beta.chat.completions.parse(
|
||||||
messages=[{"role": "user", "content": content}],
|
model=model,
|
||||||
response_format=output_format
|
messages=[{"role": "user", "content": content}],
|
||||||
)
|
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":
|
||||||
|
|
@ -170,13 +182,67 @@ 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 = False
|
silent : bool = True
|
||||||
) -> BaseModel:
|
) -> BaseModel:
|
||||||
prompt_text = prompt.build_prompt(input_data, silent)
|
prompt_text = prompt.build_prompt(input_data, silent)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
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
|
||||||
|
|
@ -6,6 +8,9 @@ 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 = [
|
||||||
|
|
@ -15,14 +20,33 @@ _SCOPES = [
|
||||||
_sheet_cache: dict[str, tuple[str, str]] = {}
|
_sheet_cache: dict[str, tuple[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_spreadsheet():
|
||||||
|
creds = Credentials.from_service_account_file(
|
||||||
|
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
|
||||||
|
)
|
||||||
|
return gspread.authorize(creds).open_by_key(prompt_settings.PROMPT_SPREADSHEET)
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
def _read_sheet_data(sheet_name: str) -> tuple[str, str]:
|
||||||
if sheet_name not in _sheet_cache:
|
if sheet_name not in _sheet_cache:
|
||||||
creds = Credentials.from_service_account_file(
|
ws = _get_spreadsheet().worksheet(sheet_name)
|
||||||
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
|
values = ws.batch_get(["B2:B3"])[0]
|
||||||
)
|
_sheet_cache[sheet_name] = (values[1][0], values[0][0])
|
||||||
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)
|
|
||||||
return _sheet_cache[sheet_name]
|
return _sheet_cache[sheet_name]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -40,19 +64,31 @@ 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)
|
||||||
build_template = self.prompt_template
|
values = verified_input.model_dump()
|
||||||
build_template = build_template.format(**verified_input.model_dump())
|
# 알려진 {필드}만 치환하고, 프롬프트 내 JSON 예시 등 리터럴 중괄호는 그대로 둔다.
|
||||||
|
# (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,
|
||||||
|
|
@ -78,16 +114,10 @@ image_autotag_prompt = Prompt(
|
||||||
)
|
)
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
def create_dynamic_subtitle_prompt(length: int) -> Prompt:
|
def create_dynamic_subtitle_prompt(length: int, industry: str = "") -> 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()
|
|
||||||
|
|
@ -3,6 +3,7 @@ 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()
|
||||||
|
|
@ -41,8 +42,64 @@ 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()
|
||||||
|
|
@ -53,8 +110,31 @@ 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()
|
||||||
|
|
@ -68,6 +148,7 @@ 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()
|
||||||
|
|
@ -76,32 +157,60 @@ 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):
|
||||||
#ad_avaliable : bool = Field(..., description="광고 영상 사용 가능 이미지 여부")
|
"""이미지 태깅 프롬프트의 LLM 출력 스키마.
|
||||||
|
공간·피사체·카메라·모션 태그와 내러티브 적합도 점수를 담아 반환.
|
||||||
|
마케팅 사용 가능 여부는 크롤링 단계(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="카메라 정보를 가지는 태그 리스트")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
GENRE_VALUES = Literal["kpop", "pop", "ballad", "hip-hop", "rnb", "edm", "jazz", "rock"]
|
||||||
|
|
||||||
# Input 정의
|
# Input 정의
|
||||||
class LyricPromptInput(BaseModel):
|
class LyricPromptInput(BaseModel):
|
||||||
|
|
@ -8,9 +10,16 @@ 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 = "가사 언어")
|
||||||
timing_rules : str = Field(..., description = "시간 제어문")
|
genre : str = Field(default="", description = "지정된 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). 비어있으면 GPT가 무드에 맞는 장르를 자유롭게 추천")
|
||||||
|
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를 제외한 나머지 중 가사 무드에 가장 잘 맞는 장르를 직접 선택해 반환",
|
||||||
|
)
|
||||||
|
|
@ -6,6 +6,12 @@ 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):
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ 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):
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ 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):
|
||||||
|
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
# 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}
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import copy
|
import copy
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
from typing import Literal, Any
|
from typing import Literal, Any
|
||||||
|
|
@ -12,20 +13,42 @@ 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)
|
||||||
|
|
||||||
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:
|
@staticmethod
|
||||||
|
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))
|
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list), industry)
|
||||||
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 = {
|
||||||
|
|
@ -34,12 +57,45 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -113,11 +113,11 @@ class SunoService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: 가사 (customMode=true일 때 가사로 사용)
|
prompt: 가사 (customMode=true일 때 가사로 사용)
|
||||||
1분 이내 길이의 노래에 적합한 가사여야 함
|
40초 이내 길이의 노래에 적합한 가사여야 함
|
||||||
genre: 음악 장르 (예: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM")
|
genre: 음악 장르 (예: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM", "Rock", "Jazz")
|
||||||
None일 경우 style 파라미터를 전송하지 않음
|
None일 경우 style 파라미터를 전송하지 않음
|
||||||
callback_url: 생성 완료 시 알림 받을 URL (None일 경우 config에서 기본값 사용)
|
callback_url: 생성 완료 시 알림 받을 URL (None일 경우 config에서 기본값 사용)
|
||||||
instrumental: True이면 BGM 전용 — 더미 가사로 60초 길이를 유도하고 보컬 없이 생성
|
instrumental: True이면 BGM 전용 — 더미 가사로 40초 길이를 유도하고 보컬 없이 생성
|
||||||
|
|
||||||
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분 내 생성
|
||||||
- 생성되는 노래는 약 1분 이내의 길이
|
- 생성되는 노래는 약 40초 이내의 길이
|
||||||
"""
|
"""
|
||||||
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 1 minute - Must be around 60 seconds]\n{bgm_lyrics}"
|
formatted_prompt = f"[Song Duration: Around 40 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: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}"
|
f"[Song Duration: Around 40 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 60 seconds" if instrumental else normalized_genre
|
payload["style"] = f"{normalized_genre}, around 40 seconds" if instrumental else normalized_genre
|
||||||
|
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
# -*- 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}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
# -*- 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]
|
||||||
|
|
@ -26,13 +26,12 @@ 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, ImageTag
|
from app.home.models import Image, Project, MarketingIntel
|
||||||
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
|
from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP
|
||||||
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 (
|
||||||
|
|
@ -59,7 +58,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
|
||||||
|
|
||||||
|
|
@ -192,6 +191,8 @@ 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(
|
||||||
|
|
@ -199,27 +200,28 @@ async def generate_video(
|
||||||
)
|
)
|
||||||
marketing_intelligence: MarketingIntel = marketing_result.scalar_one_or_none()
|
marketing_intelligence: MarketingIntel = marketing_result.scalar_one_or_none()
|
||||||
|
|
||||||
# subtitle 미완료 시 즉시 반환 — Lyric/Song/Image 쿼리 전에 체크하여 불필요한 조회 방지
|
# 자막 + 이미지 배정 미완료 시 즉시 반환 — Lyric/Song/Image 쿼리 전에 체크하여 불필요한 조회 방지
|
||||||
# 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도
|
# 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도
|
||||||
if not marketing_intelligence.subtitle:
|
if not marketing_intelligence.subtitle or not marketing_intelligence.image_match:
|
||||||
logger.info(f"[generate_video] subtitle pending - task_id: {task_id}")
|
pending_what = []
|
||||||
|
if not marketing_intelligence.subtitle:
|
||||||
|
pending_what.append("자막")
|
||||||
|
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="자막 생성이 아직 완료되지 않았습니다. /lyric/subtitle/status/{task_id}로 완료 확인 후 재요청하세요.",
|
message=f"{pending_msg} 생성이 아직 완료되지 않았습니다. /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)
|
||||||
|
|
@ -352,7 +354,8 @@ 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})"
|
||||||
|
|
@ -365,40 +368,37 @@ 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에서 리소스 매핑 생성
|
||||||
# modifications = creatomate_service.elements_connect_resource_blackbox(
|
# 이미지 배정은 /lyric/generate 사전 단계에서 이미 수행되어 marketing_intelligence.image_match에 저장됨.
|
||||||
# elements=template["source"]["elements"],
|
# 여기서는 사전 산출물을 읽어 음악 URL과 주소만 보완한다.
|
||||||
# image_url_list=image_urls,
|
modifications: dict = dict(marketing_intelligence.image_match) # 슬롯→image_url 사전 배정 결과
|
||||||
# music_url=music_url,
|
modifications["audio-music"] = music_url
|
||||||
# address=store_address
|
# address_input 슬롯은 사전 단계에서도 채워지지만 영상 시점에 재확인
|
||||||
taged_image_list = await get_image_tags_by_task_id(task_id)
|
for key in list(modifications.keys()):
|
||||||
min_image_num = creatomate_service.counting_component(
|
if "address_input" in key:
|
||||||
template = template,
|
modifications[key] = store_address
|
||||||
target_template_type = "image"
|
logger.info(f"[generate_video] image_match loaded from DB (slots: {len(modifications)}) - task_id: {task_id}")
|
||||||
)
|
|
||||||
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)
|
||||||
|
|
||||||
# revert thumbnail scene
|
# 썸네일 텍스트: 사전 자막 생성(LLM, 다국어) 결과에 thumb-* 슬롯이 포함되면
|
||||||
# thumbnail_modifications = creatomate_service.make_thumbnail_modification(
|
# 그대로 사용한다. 과거 파이프라인 산출물(subtitle에 thumb-* 없음)은
|
||||||
# brand_name =brand_name,
|
# 기존 팩트값 조립(한국어)으로 폴백.
|
||||||
# region = region,
|
thumbnail_fallback = creatomate_service.make_thumbnail_modification(
|
||||||
# brand_concept = brand_concept,
|
brand_name =brand_name,
|
||||||
# category_definition= category_definition,
|
region = region,
|
||||||
# target_keywords=target_keywords)
|
category_definition= category_definition,
|
||||||
|
target_keywords=target_keywords,
|
||||||
|
detail_region_info=store_address)
|
||||||
|
|
||||||
# modifications.update(thumbnail_modifications)
|
for slot_name, fallback_value in thumbnail_fallback.items():
|
||||||
|
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,12 +423,8 @@ 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}")
|
||||||
|
|
||||||
match lyric_language:
|
# 가사 자막 폰트: CJK/태국어는 글리프 지원 폰트로, 그 외는 Noto Sans
|
||||||
case "English" :
|
lyric_font = LANGUAGE_FONT_MAP.get(lyric_language, "Noto Sans")
|
||||||
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):
|
||||||
|
|
@ -448,6 +444,14 @@ 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)}"
|
||||||
# )
|
# )
|
||||||
|
|
|
||||||
|
|
@ -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,9 +854,19 @@ 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)
|
||||||
print(rows)
|
# img_tag가 JSON 리터럴 null로 저장된 행(태깅 실패 잔재)은 SQL IS NOT NULL 필터를
|
||||||
print("image" , [{"image_url": row.img_url, "image_tag": row.img_tag} for row in rows])
|
# 통과하므로 파이썬 레벨에서 한 번 더 걸러낸다.
|
||||||
return [{"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]
|
||||||
|
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
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- 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;
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- 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;
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- 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;
|
||||||
Loading…
Reference in New Issue