Compare commits

..

No commits in common. "4d8462651a8a8a17c6c3f6030ded8bb2908c6944" and "04d86149194e7c3f6f18a2843202a6d5dc2a881b" have entirely different histories.

21 changed files with 1123 additions and 1861 deletions

View File

@ -41,8 +41,6 @@ from app.utils.nvMapPwScraper import NvMapPwScraper
from app.utils.prompts.prompts import marketing_prompt from app.utils.prompts.prompts import marketing_prompt
from app.utils.address_parser import extract_region_from_address from app.utils.address_parser import extract_region_from_address
from app.utils.autotag import autotag_images from app.utils.autotag import autotag_images
from app.utils.image_filter import filter_marketing_images, assemble_images
from app.video.services.video import get_image_tags_by_task_id
from config import MEDIA_ROOT from config import MEDIA_ROOT
logger = get_logger("home") logger = get_logger("home")
@ -104,7 +102,7 @@ async def _resolve_industry(category: str, customer_name: str = "") -> str:
"""업체를 통합 프롬프트의 8개 industry enum 중 하나로 AI 분류. """업체를 통합 프롬프트의 8개 industry enum 중 하나로 AI 분류.
분류 근거는 카테고리를 우선 사용하고, 카테고리가 없으면 업체명으로 분류한다. 분류 근거는 카테고리를 우선 사용하고, 카테고리가 없으면 업체명으로 분류한다.
근거가 으면 문자열 반환. API 장애 예외 전파(하위 분석도 동일 API 의존). 근거가 거나 분류 실패 문자열 반환(프롬프트가 범용 처리).
""" """
if category: if category:
basis_label, basis_value = "네이버 지도 카테고리", category basis_label, basis_value = "네이버 지도 카테고리", category
@ -112,6 +110,7 @@ async def _resolve_industry(category: str, customer_name: str = "") -> str:
basis_label, basis_value = "업체명", customer_name basis_label, basis_value = "업체명", customer_name
else: else:
return "" return ""
try:
chatgpt = ChatgptService() chatgpt = ChatgptService()
prompt = ( prompt = (
f"{basis_label}: '{basis_value}'\n" f"{basis_label}: '{basis_value}'\n"
@ -128,6 +127,9 @@ async def _resolve_industry(category: str, customer_name: str = "") -> str:
image_detail_high=False, image_detail_high=False,
) )
return result.industry return result.industry
except Exception as e:
logger.warning(f"[industry] AI 분류 실패, 빈 값 반환: {e}")
return ""
@router.post( @router.post(
@ -245,12 +247,12 @@ async def _crawling_logic(
) )
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
image_count = len(scraper.image_link_list) if scraper.image_link_list else 0
logger.info( logger.info(
f"[crawling] Step 1 완료 - 업체사진 {len(scraper.owner_images or [])}개, " f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)"
f"보충사진 {len(scraper.extra_photo_urls or [])}개 ({step1_elapsed:.1f}ms)"
) )
# ========== Step 2: 정보 가공 (industry 선행 계산) ========== # ========== Step 2: 정보 가공 ==========
step2_start = time.perf_counter() step2_start = time.perf_counter()
logger.info("[crawling] Step 2: 정보 가공 시작...") logger.info("[crawling] Step 2: 정보 가공 시작...")
@ -263,21 +265,7 @@ async def _crawling_logic(
customer_name = scraper.base_info.get("name", "") customer_name = scraper.base_info.get("name", "")
category = scraper.base_info.get("category", "") 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) 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,
@ -289,53 +277,15 @@ async def _crawling_logic(
step2_elapsed = (time.perf_counter() - step2_start) * 1000 step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info( logger.info(
f"[crawling] Step 2 완료 - {customer_name}, {region}, " f"[crawling] Step 2 완료 - {customer_name}, {region}, "
f"category={category!r}, industry={industry!r} ({step2_elapsed:.1f}ms)" f"category={category!r}, place_type={scraper.place_type!r}, industry={industry!r} ({step2_elapsed:.1f}ms)"
) )
# ========== Step 3: 이미지 마케팅 적합성 필터링 ========== # ========== Step 3: ChatGPT 마케팅 분석 ==========
# 업체 사진이 SUPPLEMENT_THRESHOLD(30장) 이상이면 보충이 불필요하므로
# 방문자 사진 필터링(Gemini 호출) 자체를 건너뛴다.
step3_start = time.perf_counter() step3_start = time.perf_counter()
owner_images = scraper.owner_images or [] logger.info("[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
extra_photo_urls = scraper.extra_photo_urls or []
if len(owner_images) >= NvMapScraper.SUPPLEMENT_THRESHOLD:
scraper.image_link_list = owner_images[: NvMapScraper.MAX_IMAGES]
step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.info(
f"[crawling] Step 3 SKIP - 업체 사진 {len(owner_images)}"
f"{NvMapScraper.SUPPLEMENT_THRESHOLD}장 → 방문자 사진 필터링 생략"
)
else:
try:
extra_pass_flags = await filter_marketing_images(
[img["original"] for img in extra_photo_urls], industry
)
except Exception as e:
logger.error(f"[crawling] Step 3 FAILED - 이미지 필터링 중 오류: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="이미지 마케팅 적합성 필터링 중 오류가 발생했습니다.",
)
scraper.image_link_list = assemble_images(
owner_images, extra_photo_urls, extra_pass_flags, NvMapScraper.MAX_IMAGES
)
passed_count = sum(extra_pass_flags)
step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.info(
f"[crawling] Step 3 완료 - 방문자 사진 {len(extra_photo_urls)}장 중 "
f"{passed_count}장 통과 → 최종 이미지 {len(scraper.image_link_list)}"
f"({step3_elapsed:.1f}ms)"
)
if not scraper.image_link_list:
logger.warning("[crawling] Step 3 - 필터링 후 사용 가능 이미지가 0장입니다.")
# ========== Step 4: ChatGPT 마케팅 분석 ==========
step4_start = time.perf_counter()
logger.info("[crawling] Step 4: ChatGPT 마케팅 분석 시작...")
try: try:
# Step 4-1: ChatGPT 서비스 초기화 및 입력 데이터 구성 # Step 3-1: ChatGPT 서비스 초기화 및 입력 데이터 구성
chatgpt_service = ChatgptService() chatgpt_service = ChatgptService()
input_marketing_data = { input_marketing_data = {
"customer_name": customer_name, "customer_name": customer_name,
@ -344,12 +294,12 @@ async def _crawling_logic(
"industry": industry, # 통합 프롬프트 내부 분기용 업종 enum "industry": industry, # 통합 프롬프트 내부 분기용 업종 enum
} }
# Step 4-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환 # Step 3-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환
marketing_analysis = await chatgpt_service.generate_structured_output( marketing_analysis = await chatgpt_service.generate_structured_output(
marketing_prompt, input_marketing_data marketing_prompt, input_marketing_data
) )
# Step 4-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장) # Step 3-3: 분석 결과 DB 저장 (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(),
@ -360,26 +310,26 @@ async def _crawling_logic(
m_id = marketing_intel.id m_id = marketing_intel.id
logger.debug(f"[MarketingPrompt] INSERT place_id={marketing_intel.place_id} id={marketing_intel.id}") logger.debug(f"[MarketingPrompt] INSERT place_id={marketing_intel.place_id} id={marketing_intel.id}")
step4_elapsed = (time.perf_counter() - step4_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.info( logger.info(
f"[crawling] Step 4 완료 - 마케팅 분석 성공 ({step4_elapsed:.1f}ms)" f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)"
) )
except ChatGPTResponseError as e: except ChatGPTResponseError as e:
step4_elapsed = (time.perf_counter() - step4_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.error( logger.error(
f"[crawling] Step 4 FAILED - ChatGPT Error: status={e.status}, " f"[crawling] Step 3 FAILED - ChatGPT Error: status={e.status}, "
f"code={e.error_code}, message={e.error_message} ({step4_elapsed:.1f}ms)" f"code={e.error_code}, message={e.error_message} ({step3_elapsed:.1f}ms)"
) )
marketing_analysis = None marketing_analysis = None
gpt_status = "failed" gpt_status = "failed"
except Exception as e: except Exception as e:
step4_elapsed = (time.perf_counter() - step4_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.error( logger.error(
f"[crawling] Step 4 FAILED - GPT 마케팅 분석 중 오류: {e} ({step4_elapsed:.1f}ms)" f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)"
) )
logger.exception("[crawling] Step 4 상세 오류:") logger.exception("[crawling] Step 3 상세 오류:")
marketing_analysis = None marketing_analysis = None
gpt_status = "failed" gpt_status = "failed"
else: else:
@ -396,9 +346,7 @@ async def _crawling_logic(
if scraper.base_info: if scraper.base_info:
logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms") logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
if "step3_elapsed" in locals(): if "step3_elapsed" in locals():
logger.info(f"[crawling] - Step 3 (이미지 필터링): {step3_elapsed:.1f}ms") logger.info(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
if "step4_elapsed" in locals():
logger.info(f"[crawling] - Step 4 (GPT 분석): {step4_elapsed:.1f}ms")
return { return {
"status": gpt_status if 'gpt_status' in locals() else "completed", "status": gpt_status if 'gpt_status' in locals() else "completed",
@ -421,7 +369,6 @@ async def _crawling_logic(
## 요청 필드 ## 요청 필드
- **customer_name**: 업체명 / 브랜드명 (필수) - **customer_name**: 업체명 / 브랜드명 (필수)
- **address**: 도로명 또는 지번 주소 (필수) - **address**: 도로명 또는 지번 주소 (필수)
- **category**: 업종/카테고리 자유 입력 (선택, : 펜션, 카페). 비우면 업체명 기반 AI 분류
## 반환 정보 ## 반환 정보
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info) - **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
@ -446,9 +393,9 @@ 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가 있으면 사용, 없으면 업체명 기반 AI 분류
industry = await _resolve_industry( industry = request_body.industry or await _resolve_industry(
request_body.category, request_body.store_name "", request_body.store_name
) )
processed_info.industry = industry processed_info.industry = industry
input_marketing_data = { input_marketing_data = {
@ -656,10 +603,6 @@ async def upload_images_blob(
default=None, default=None,
description="이미지 바이너리 파일 목록", description="이미지 바이너리 파일 목록",
), ),
industry: str = Form(
default="",
description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 크롤링 응답의 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)
@ -861,21 +804,9 @@ async def upload_images_blob(
image_urls = [img.img_url for img in result_images] image_urls = [img.img_url for img in result_images]
logger.info(f"[image_tagging] START - task_id: {task_id}") logger.info(f"[image_tagging] START - task_id: {task_id}")
await tagging_images(image_urls, industry=industry, clear_old_tags=True) await tagging_images(image_urls, clear_old_tags=True)
logger.info(f"[image_tagging] Done - task_id: {task_id}") logger.info(f"[image_tagging] Done - task_id: {task_id}")
# 태깅 직후 영상 생성에 사용 가능한 이미지가 하나도 없으면 조기에 실패시킨다.
# (여기서 걸러지지 않으면 훨씬 나중인 영상 생성 단계에서야 슬롯 미배정으로 발견됨)
# marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되었으므로 여기서는 재필터링하지 않는다.
taged_image_list = await get_image_tags_by_task_id(task_id)
logger.info(f"태깅된 이미지: {len(taged_image_list)}개 - task_id: {task_id}")
if not taged_image_list:
logger.error(f"[image_tagging] 영상 생성에 적합한 이미지가 없음 - task_id: {task_id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="영상 생성에 적합한 이미지가 없습니다. 다른 이미지로 다시 업로드해주세요.",
)
total_time = time.perf_counter() - request_start total_time = time.perf_counter() - request_start
logger.info( logger.info(
f"[upload_images_blob] SUCCESS - task_id: {task_id}, " f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
@ -895,7 +826,6 @@ async def upload_images_blob(
async def tagging_images( async def tagging_images(
image_urls : list[str], image_urls : list[str],
industry: str = "",
clear_old_tags : bool = False clear_old_tags : bool = False
) -> None: ) -> None:
# 1. 조회 # 1. 조회
@ -921,8 +851,8 @@ async def tagging_images(
await session.commit() await session.commit()
if null_imts: if null_imts:
tag_datas = await autotag_images([img.img_url for img in null_imts], industry=industry) tag_datas = await autotag_images([img.img_url for img in null_imts])
# print(tag_datas) print(tag_datas)
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
for tag, tag_data in zip(null_imts, tag_datas): for tag, tag_data in zip(null_imts, tag_datas):

View File

@ -116,7 +116,7 @@ class Project(Base):
industry: Mapped[str] = mapped_column( industry: Mapped[str] = mapped_column(
String(50), String(50),
nullable=False, nullable=True,
default="", default="",
comment="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction)", comment="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction)",
) )
@ -314,12 +314,6 @@ class MarketingIntel(Base):
comment="자막 정보 생성 결과물", comment="자막 정보 생성 결과물",
) )
image_match : Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=True,
comment="이미지 슬롯 배정 결과물 — {슬롯명: image_url, 'audio-music': music_url, ...}",
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,

View File

@ -98,12 +98,6 @@ class ProcessedInfo(BaseModel):
# selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록") # selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
class ImageListItem(BaseModel):
"""크롤링 이미지 아이템 (미리보기 URL + 원본 URL)"""
preview: str = Field(..., description="미리보기용 CDN URL")
original: str = Field(..., description="업로드용 원본 URL")
class CrawlingResponse(BaseModel): class CrawlingResponse(BaseModel):
"""크롤링 응답 스키마""" """크롤링 응답 스키마"""
@ -111,7 +105,7 @@ class CrawlingResponse(BaseModel):
json_schema_extra={ json_schema_extra={
"example": { "example": {
"status": "completed", "status": "completed",
"image_list": [{"preview": "https://example.com/image1.jpg", "original": "https://example.com/image1.jpg"}], "image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"],
"image_count": 2, "image_count": 2,
"processed_info": { "processed_info": {
"customer_name": "스테이 머뭄", "customer_name": "스테이 머뭄",
@ -240,7 +234,7 @@ class CrawlingResponse(BaseModel):
default="completed", default="completed",
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)" description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
) )
image_list: Optional[list[ImageListItem]] = Field(None, description="이미지 목록 (preview/original URL)") image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
image_count: Optional[int] = Field(None, description="이미지 개수") image_count: Optional[int] = Field(None, description="이미지 개수")
processed_info: Optional[ProcessedInfo] = Field( processed_info: Optional[ProcessedInfo] = Field(
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)" None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
@ -260,14 +254,13 @@ 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 분류") industry: str = Field(default="", description="종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 비우면 업체명 기반 AI 분류")
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):

View File

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

View File

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

View File

@ -78,16 +78,6 @@ class GenerateLyricRequest(BaseModel):
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)") industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction)")
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가 가사 무드에 맞춰 추천",
)
genre: Optional[str] = Field(
None,
description="사용자가 수동으로 고른 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). "
"'자동 선택'인 경우 None으로 전달하면 GPT가 가사 무드에 맞춰 추천",
)
class GenerateLyricResponse(BaseModel): class GenerateLyricResponse(BaseModel):
@ -210,10 +200,6 @@ class LyricDetailResponse(BaseModel):
project_id: int = Field(..., description="프로젝트 ID") project_id: int = Field(..., description="프로젝트 ID")
status: str = Field(..., description="처리 상태 (processing, completed, failed)") status: str = Field(..., description="처리 상태 (processing, completed, failed)")
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)") lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
genre: Optional[str] = Field(
None,
description="확정된 음악 장르. 수동 선택 시 요청값, 자동 선택 시 GPT 추천값 (완료 전에는 None)",
)
created_at: Optional[datetime] = Field(None, description="생성 일시") created_at: Optional[datetime] = Field(None, description="생성 일시")

View File

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

View File

@ -6,11 +6,10 @@ from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommen
import asyncio import asyncio
async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_list async def autotag_image(image_url : str) -> list[str]: #tag_list
chatgpt = ChatgptService(model_type="gemini") chatgpt = ChatgptService(model_type="gemini")
image_input_data = { image_input_data = {
"img_url" : image_url, "img_url" : image_url,
"industry" : industry,
"space_type" : list(SpaceType), "space_type" : list(SpaceType),
"subject" : list(Subject), "subject" : list(Subject),
"camera" : list(Camera), "camera" : list(Camera),
@ -20,11 +19,10 @@ async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False) image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False)
return image_result return image_result
async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_list
chatgpt = ChatgptService(model_type="gemini") chatgpt = ChatgptService(model_type="gemini")
image_input_data_list = [{ image_input_data_list = [{
"img_url" : image_url, "img_url" : image_url,
"industry" : industry,
"space_type" : list(SpaceType), "space_type" : list(SpaceType),
"subject" : list(Subject), "subject" : list(Subject),
"camera" : list(Camera), "camera" : list(Camera),

View File

@ -233,7 +233,7 @@ DHST0002 = "3f194cc7-464e-4581-9db2-179d42d3e40f"
DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d" DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d"
DVST0001T = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6" DVST0001T = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
HST_LIST = [DHST0001,DHST0002,DHST0003] HST_LIST = [DHST0001,DHST0002,DHST0003]
VST_LIST = [DVST0001T] VST_LIST = [DVST0001,DVST0002,DVST0003, DVST0001T]
SCENE_TRACK = 1 SCENE_TRACK = 1
AUDIO_TRACK = 2 AUDIO_TRACK = 2
@ -242,13 +242,11 @@ KEYWORD_TRACK = 4
def select_template(orientation:OrientationType): def select_template(orientation:OrientationType):
if orientation == "horizontal": if orientation == "horizontal":
template_id = DHST0001 return DHST0001
elif orientation == "vertical": elif orientation == "vertical":
template_id = random.choice(VST_LIST) return DVST0001T
else: else:
raise raise
logger.info(f"[select_template] orientation={orientation}, template_id={template_id}")
return template_id
async def get_shared_client() -> httpx.AsyncClient: async def get_shared_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
@ -458,155 +456,50 @@ class CreatomateService:
def template_matching_taged_image( def template_matching_taged_image(
self, self,
template : dict, template : dict,
taged_image_list: list, # [{"image_url": str, "image_tag": dict}] — 이미 마케팅 적합성 필터를 통과한 이미지만 전달할 것 taged_image_list : list, # [{"image_name" : str , "image_tag" : dict}]
music_url: str, music_url: str,
address : str, address : str,
duplicate : bool = False duplicate : bool = False
) -> tuple[dict, dict]: ) -> list:
"""템플릿 슬롯에 이미지를 배정합니다.
Returns:
(modifications, assigned) 튜플
- modifications: {슬롯명: image_url, "audio-music": music_url, ...} modify_element에 전달
- assigned: {슬롯명: image_tag} 자막 생성 컨텍스트에 전달 (modify_element에 넣지 않음)
배정 전략 (duplicate=False):
- 순수 greedy pop 대신 "난이도 우선 greedy" 적용
- 사전에 전체 슬롯 점수를 계산해 최고 달성 점수가 낮은(=까다로운) 슬롯을 먼저 배정
- 이미지 배정 직전 점수를 재계산 (pop 이후 인덱스 변동 대응)
배정 전략 (duplicate=True, 이미지 < 슬롯):
- 1단계(최소 커버리지 보장): 이미지별로 가장 맞는 슬롯을 찾아 pool의 모든 이미지를
서로 다른 슬롯에 먼저 배정한다 (이미지 <= 슬롯 수이므로 전량 배정 가능).
슬롯을 먼저 소진시키는 것이 아니라 "이미지" 기준으로 난이도 우선 배정하여,
특정 이미지 장이 여러 슬롯을 독점하는 것을 방지한다.
- 2단계: 1단계에서 채운 나머지 슬롯은 기존처럼 pool 전체에서 최고점 이미지를
배정한다 (여기서부터는 중복 재사용 불가피).
Note:
marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되므로 여기서는 재필터링하지 않는다.
taged_image_list를 그대로 pool로 사용한다.
"""
source_elements = template["source"]["elements"] source_elements = template["source"]["elements"]
template_component_data = self.parse_template_component_name(source_elements) template_component_data = self.parse_template_component_name(source_elements)
pool = taged_image_list taged_image_list = [img for img in taged_image_list if img.get("image_tag") is not None]
modifications: dict = {} modifications = {}
assigned: dict = {} # {슬롯명: image_tag} — 자막 컨텍스트용
# 이미지 슬롯과 텍스트 슬롯 분리 for slot_idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
image_slots = [name for name, t in template_component_data.items() if t == "image"] match template_type:
text_slots = [(name, t) for name, t in template_component_data.items() if t == "text"] case "image":
image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name)
if not pool: if duplicate:
logger.warning("[template_matching_taged_image] 태그된 이미지가 없음 — 이미지 슬롯 배정 불가") # 이미지 부족(슬롯 수 > 이미지 수): 재사용이 불가피하므로 슬롯에 가장 적합한 이미지(최고점) 선택
elif duplicate: maximum_idx = image_score_list.index(max(image_score_list))
# 이미지 부족(슬롯 수 > 이미지 수) selected = taged_image_list[maximum_idx]
# 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.calculate_image_slot_score_multi([image], slot)[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.calculate_image_slot_score_multi(pool, slot)
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: else:
# 이미지 충분(슬롯 수 <= 이미지 수): 난이도 우선 greedy 배정 # 이미지 충분(슬롯 수 <= 이미지 수): 상위 3장 중 점수 비례 확률로 선택해 영상 다양화
# Step 1: 정렬용 사전 점수 계산 (최고 달성 점수가 낮을수록 배정이 어려운 슬롯) # 동일 요청을 반복할 때 매번 같은 영상이 생성되는 문제 방지
prelim = {slot: self.calculate_image_slot_score_multi(pool, slot) for slot in image_slots} sorted_indices = sorted(range(len(image_score_list)), key=lambda i: image_score_list[i], reverse=True)
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.calculate_image_slot_score_multi(pool, slot)
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))] top_indices = sorted_indices[:min(3, len(sorted_indices))]
weights = [scores[i] for i in top_indices] weights = [image_score_list[i] for i in top_indices]
if sum(weights) > 0: if sum(weights) > 0:
chosen_pool_idx = random.choices(top_indices, weights=weights, k=1)[0] maximum_idx = random.choices(top_indices, weights=weights, k=1)[0]
else: else:
chosen_pool_idx = random.choice(top_indices) maximum_idx = random.choice(top_indices)
selected = taged_image_list.pop(maximum_idx)
selected = pool.pop(chosen_pool_idx) image_name = selected["image_url"]
modifications[slot] = selected["image_url"] modifications[template_component_name] =image_name
assigned[slot] = selected["image_tag"] pass
case "text":
# 텍스트 슬롯 처리 if "address_input" in template_component_name:
for name, _ in text_slots: modifications[template_component_name] = address
if "address_input" in name:
modifications[name] = address
modifications["audio-music"] = music_url modifications["audio-music"] = music_url
return modifications, assigned return modifications
# 점수 계산 상수 (모듈 레벨로 빼면 더 좋지만 기존 코드 스타일 따라 클래스 내부에 위치)
_NARR_FLOOR = 0.25 # narrative 최솟값 modulator: base 점수가 완전히 0이 되는 것을 방지
_NARR_BONUS = 1.5 # narrative 독립 보너스: base=0이어도 narrative 신호가 점수에 남음
_NARR_MIN = 0.0 # narrative_preference 값 clamp 하한 (무경계 스키마 방어)
_NARR_MAX = 100.0 # narrative_preference 값 clamp 상한. NarrativePreference 스키마(0.0~100.0)와 스케일 일치
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
- base: 태그 카테고리 매칭 합산 (최대 5.5)
- narr_mod: [NARR_FLOOR, 1.0] 구간으로 완화된 narrative 계수
- NARR_BONUS * narr: narrative 독립 보너스 (base=0이어도 신호 유지)
구조로 "base=0, narrative가 높음" "완전히 틀린 이미지(0,0)" 구별할 있음.
"""
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list] image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
slot_tag_dict = self.parse_slot_name_to_tag(slot_name) slot_tag_dict = self.parse_slot_name_to_tag(slot_name)
if slot_tag_dict is None: image_score_list = [0] * len(image_tag_list)
# parse 실패 시 전부 0점 반환 (슬롯 skip)
return [0.0] * len(image_tag_list)
base_score_list = [0.0] * len(image_tag_list)
# slot_tag_narrative = NarrativePhase.accent # 기본값 (narrative 토큰 없는 슬롯 대비)
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":
@ -615,45 +508,28 @@ class CreatomateService:
match slot_tag_cate: match slot_tag_cate:
case "space_type": case "space_type":
weight = 2.0 weight = 2
case "subject" : case "subject" :
weight = 2.0 weight = 2
case "camera": case "camera":
weight = 1.0 weight = 1
case "motion_recommended" : case "motion_recommended" :
weight = 0.5 weight = 0.5
case _: case _:
raise ValueError(f"[calculate_image_slot_score_multi] unknown slot tag category: {slot_tag_cate}") raise
for idx, image_tag in enumerate(image_tag_list): for idx, image_tag in enumerate(image_tag_list):
if slot_tag_item.value in image_tag[slot_tag_cate]: #collect! if slot_tag_item.value in image_tag[slot_tag_cate]: #collect!
base_score_list[idx] += weight image_score_list[idx] += weight
# narrative 점수 적용: 순수 곱셈 대신 "완화 modulator + 독립 보너스"
# - narr_mod: base를 흐릴 수 있지만 NARR_FLOOR 이하로는 안 떨어짐
# - NARR_BONUS * narr: base=0이어도 narrative가 높으면 양수 점수 확보
image_score_list = []
for idx, image_tag in enumerate(image_tag_list): for idx, image_tag in enumerate(image_tag_list):
raw_narr = image_tag.get("narrative_preference", {}).get(slot_tag_narrative, 0.0) image_narrative_score = image_tag["narrative_preference"][slot_tag_narrative]
clamped_narr = min(self._NARR_MAX, max(self._NARR_MIN, float(raw_narr))) image_score_list[idx] = image_score_list[idx] * image_narrative_score
narr = clamped_narr / self._NARR_MAX # 0.0~1.0로 정규화
narr_mod = self._NARR_FLOOR + (1.0 - self._NARR_FLOOR) * narr
score = base_score_list[idx] * narr_mod + self._NARR_BONUS * narr
image_score_list.append(score)
return image_score_list return image_score_list
def parse_slot_name_to_tag(self, slot_name: str) -> dict[str, StrEnum] | None: def parse_slot_name_to_tag(self, slot_name : str) -> dict[str, StrEnum]:
"""슬롯 이름을 파싱하여 태그 딕셔너리를 반환합니다.
슬롯 이름 형식: {space_type}-{subject}-{camera}-{motion}-{narrative}
파싱 실패 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리).
"""
try:
tag_list = slot_name.split("-") tag_list = slot_name.split("-")
if len(tag_list) < 5:
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 토큰 부족 ({len(tag_list)}개): '{slot_name}' — 슬롯 skip")
return None
space_type = SpaceType(tag_list[0]) space_type = SpaceType(tag_list[0])
subject = Subject(tag_list[1]) subject = Subject(tag_list[1])
camera = Camera(tag_list[2]) camera = Camera(tag_list[2])
@ -667,9 +543,6 @@ class CreatomateService:
"narrative_preference" : narrative, "narrative_preference" : narrative,
} }
return tag_dict return tag_dict
except (ValueError, IndexError) as e:
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 파싱 실패: '{slot_name}'{e} — 슬롯 skip")
return None
def elements_connect_resource_blackbox( def elements_connect_resource_blackbox(
self, self,
@ -915,7 +788,8 @@ class CreatomateService:
for animation in elem["animations"]: for animation in elem["animations"]:
if "reversed" in animation: if "reversed" in animation:
continue continue
if "transition" in animation and animation["transition"] and animation.get("time", 0) == 0: assert animation.get("time",0) == 0 # 0이 아닌 경우 확인 필요
if "transition" in animation and animation["transition"]:
track_maximum_duration[elem["track"]] -= animation["duration"] 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"])
@ -951,8 +825,7 @@ class CreatomateService:
if "animations" not in elem: if "animations" not in elem:
continue continue
for animation in elem["animations"]: for animation in elem["animations"]:
if animation.get("time", 0) != 0: assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
animation["time"] = animation["time"] * extend_rate
animation["duration"] = animation["duration"] * extend_rate animation["duration"] = animation["duration"] * extend_rate
except Exception as e: except Exception as e:
logger.error( logger.error(
@ -1017,7 +890,7 @@ class CreatomateService:
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]]
mod_dict = { mod_dict = {
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords), "thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),

View File

@ -1,125 +0,0 @@
"""크롤링 단계 이미지 마케팅 적합성 필터.
마케팅 적합성(marketing_acceptable) 판정은 모듈이 전담한다. 크롤링 시점에
방문자/AI View 사진(extra_photo_urls)만을 대상으로 판정하며, 업로드 이후 단계의
태깅(app/utils/autotag.py의 autotag_images(), sheet 기반 image_autotag_prompt)
공간/피사체/카메라/모션/내러티브 태그만 다루고 marketing_acceptable은 포함하지 않는다
(이미 단계에서 걸러진 이미지만 태깅 대상이 되기 때문). 프롬프트가 짧아
시트(Prompt) 대신 코드에 하드코딩한다.
업체 등록 사진(owner_images) 필터의 대상이 아니며, 호출측(라우터)에서
owner_images 개수가 NvMapScraper.SUPPLEMENT_THRESHOLD(30) 이상이면 모듈을
아예 호출하지 않는 것이 원칙이다.
"""
import asyncio
from app.utils.logger import get_logger
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.schemas import MarketingFilterOutput
logger = get_logger("image_filter")
# 크롤링 단계 필터링에 사용할 Gemini 모델. 시트(prompts.py)와 달리 코드에 고정한다.
MARKETING_FILTER_MODEL = "gemini-3.5-flash"
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="gemini")
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=False,
)
results: list[MarketingFilterOutput | BaseException] = await asyncio.gather(
*[_call(url) for url in image_url_list], return_exceptions=True
)
for _ in range(MAX_RETRY):
failed_idx = [i for i, r in enumerate(results) if isinstance(r, Exception)]
if not failed_idx:
break
logger.warning(f"[image_filter] 재시도 대상 인덱스: {failed_idx}")
retried = await asyncio.gather(
*[_call(image_url_list[i]) for i in failed_idx], return_exceptions=True
)
for i, result in zip(failed_idx, retried):
results[i] = result
final_failed = [i for i, r in enumerate(results) if isinstance(r, Exception)]
if final_failed:
logger.warning(
f"[image_filter] {len(final_failed)}건 최종 실패 → 보수적으로 제외 처리: {final_failed}"
)
return [
(not isinstance(r, Exception)) and r.marketing_acceptable
for r in results
]
def assemble_images(
owner_images: list[dict],
extra_images: list[dict],
extra_pass_flags: list[bool],
max_images: int,
) -> list[dict]:
"""owner_images 전량 + 필터 통과한 extra_images를 합쳐 최대 max_images장을 만든다.
owner_images는 무조건 포함(필터링 대상 아님). extra_images는 extra_pass_flags와
같은 순서로 매칭되며, 통과분만 순서대로 이어붙인다. original URL 기준으로 중복을
제거한다(nvMapScraper._extract_origins의 dedup 방식과 동일).
"""
passed_extra = [img for img, ok in zip(extra_images, extra_pass_flags) if ok]
seen: set[str] = set()
combined: list[dict] = []
for img in owner_images + passed_extra:
original = img.get("original")
if original and original not in seen:
seen.add(original)
combined.append(img)
return combined[:max_images]

View File

@ -52,12 +52,14 @@ class NvMapPwScraper():
cls.is_ready = True cls.is_ready = True
GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql" GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"
_PLACE_TYPES = ("restaurant", "accommodation")
@classmethod @classmethod
async def fetch_graphql( async def fetch_graphql(
cls, cls,
place_id: str, place_id: str,
payloads: list[dict], payloads: list[dict],
place_type: str | None = None,
) -> list[dict | None] | None: ) -> list[dict | None] | None:
"""실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다. """실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다.
@ -71,6 +73,8 @@ class NvMapPwScraper():
Args: Args:
place_id: 네이버 place ID place_id: 네이버 place ID
payloads: GraphQL POST 본문 목록 payloads: GraphQL POST 본문 목록
place_type: "restaurant" | "accommodation" (없으면 시도)
Returns: Returns:
payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 None. payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 None.
""" """
@ -91,7 +95,8 @@ class NvMapPwScraper():
page.on("request", on_request) page.on("request", on_request)
try: try:
for t in ("place", "restaurant", "accommodation"): types = [place_type] if place_type in cls._PLACE_TYPES else list(cls._PLACE_TYPES)
for t in types:
try: try:
await page.goto( await page.goto(
f"https://pcmap.place.naver.com/{t}/{place_id}/home", f"https://pcmap.place.naver.com/{t}/{place_id}/home",

View File

@ -35,8 +35,6 @@ class NvMapScraper:
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql" GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
REQUEST_TIMEOUT = 120 # 초 REQUEST_TIMEOUT = 120 # 초
data_source_identifier = "nv" data_source_identifier = "nv"
SUPPLEMENT_THRESHOLD = 30 # 업체 사진이 이 수 미만일 때 방문자 사진으로 보충
MAX_IMAGES = 50 # 업체+방문자 사진 합산 최대 장수
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}) {
@ -53,15 +51,7 @@ query getAccommodation($id: String!, $deviceType: String) {
visitorReviewsTotal visitorReviewsTotal
} }
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
}
} }
}""" }"""
@ -94,11 +84,10 @@ query getVisitorReviewStats($id: String!) {
) )
self.scrap_type: str | None = None self.scrap_type: str | None = None
self.rawdata: dict | None = None self.rawdata: dict | None = None
self.image_link_list: list[dict] | None = None # [{"preview": str, "original": str}] self.image_link_list: list[str] | None = None
self.owner_images: list[dict] | None = None # 업체 등록 사진 (마케팅 필터 제외 대상)
self.extra_photo_urls: list[dict] | None = None # 방문자/AI View 보충 사진 (마케팅 필터 대상)
self.base_info: dict | None = None self.base_info: dict | None = None
self.facility_info: str | None = None self.facility_info: str | None = None
self.place_type: str | None = None # 크롤링으로 감지된 업종 (accommodation | restaurant)
self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count) self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count)
def _get_request_headers(self) -> dict: def _get_request_headers(self) -> dict:
@ -107,62 +96,6 @@ query getVisitorReviewStats($id: String!) {
headers["Cookie"] = self.cookies headers["Cookie"] = self.cookies
return headers return headers
_GIF_PATTERN = re.compile(r"\.gif(?:/|$)", re.IGNORECASE)
@classmethod
def _is_gif_url(cls, url: str) -> bool:
"""URL의 확장자가 gif인지 확인한다.
네이버 CDN은 리사이즈 파라미터를 확장자 뒤에 경로 세그먼트로 붙인다
(: ".../3982000924.gif/500x500") endswith(".gif")로는 잡히지 않으므로
경로 세그먼트 단위로 ".gif" 뒤에 "/" 또는 문자열 끝이 오는지 검사한다.
"""
return bool(cls._GIF_PATTERN.search(url))
@staticmethod
def _extract_origins(image_node: dict) -> list[dict]:
"""GraphQL 이미지 노드({ images: [{origin, url}, ...] })에서 {preview, original} 목록을 추출한다.
origin = 원본(업로드용), url = CDN 리사이즈(미리보기용)
"""
items = image_node.get("images") or []
return [
{"preview": item.get("url") or item["origin"], "original": item["origin"]}
for item in items
if item.get("origin") and not NvMapScraper._is_gif_url(item["origin"])
]
@staticmethod
def _extract_photo_viewer_urls(photo_viewer_raw: dict | None) -> list[dict]:
"""getPhotoViewerItems 응답에서 {preview, original} 목록을 추출한다.
photoViewer는 썸네일 필드가 없으므로 preview = original 동일하게 사용.
"""
photos = ((photo_viewer_raw or {}).get("data") or {}).get("photoViewer") or {}
items = photos.get("photos") or []
return [
{"preview": p["originalUrl"], "original": p["originalUrl"]}
for p in items
if p.get("originalUrl") and not NvMapScraper._is_gif_url(p["originalUrl"])
]
@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+)"
@ -181,6 +114,9 @@ query getVisitorReviewStats($id: String!) {
# place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161) # place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161)
type_match = re.search(r"place\.naver\.com/([a-zA-Z]+)/(\d+)", self.url) type_match = re.search(r"place\.naver\.com/([a-zA-Z]+)/(\d+)", self.url)
if type_match: if type_match:
url_place_type = type_match.group(1)
if url_place_type in ("accommodation", "restaurant"):
self.place_type = url_place_type
return type_match.group(2) 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")
@ -208,7 +144,7 @@ query getVisitorReviewStats($id: String!) {
# self.scrap_type = "GraphQL-Browser" # self.scrap_type = "GraphQL-Browser"
# ── 실제 브라우저로 WTM 캡차 우회 ── # ── 실제 브라우저로 WTM 캡차 우회 ──
data, stats_data, extra_photo_urls = await self._scrap_via_browser(place_id) data, stats_data = await self._scrap_via_browser(place_id)
fac_data = None # 편의시설(HTML)은 브라우저 경로에서 생략 (best-effort) fac_data = None # 편의시설(HTML)은 브라우저 경로에서 생략 (best-effort)
self.scrap_type = "GraphQL-Browser" self.scrap_type = "GraphQL-Browser"
@ -216,41 +152,21 @@ query getVisitorReviewStats($id: String!) {
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것. # Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
self.place_id = self.data_source_identifier + place_id self.place_id = self.data_source_identifier + place_id
self.rawdata["facilities"] = fac_data self.rawdata["facilities"] = fac_data
business = data["data"]["business"] self.image_link_list = [
nv_image["origin"]
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup) for nv_image in data["data"]["business"]["images"]["images"]
self.owner_images = self._dedup_by_original(self._extract_origins(business.get("images") or {})) ]
# 방문자/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로 재조립할 것.)
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}장)"
)
else:
combined = self.owner_images
self.image_link_list = combined[: self.MAX_IMAGES]
self.base_info = data["data"]["business"]["base"] self.base_info = data["data"]["business"]["base"]
self.facility_info = fac_data self.facility_info = fac_data
self.voted_keyword_stats = stats_data self.voted_keyword_stats = stats_data
return return
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[str]]: async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None]:
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다. """직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
Returns: Returns:
(overview_data, review_stats_details, extra_photo_urls) (overview_data, review_stats_details)
Raises: Raises:
GraphQLException: 브라우저 폴백마저 실패한 경우 GraphQLException: 브라우저 폴백마저 실패한 경우
@ -267,97 +183,32 @@ query getVisitorReviewStats($id: String!) {
"variables": {"id": place_id}, "variables": {"id": place_id},
"query": self.REVIEW_STATS_QUERY, "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,
}
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload]
MAX_RETRY = 3
results = None
last_error: Exception | None = None
for attempt in range(1, MAX_RETRY + 1):
try: try:
results = await NvMapPwScraper.fetch_graphql(place_id, payloads) results = await NvMapPwScraper.fetch_graphql(
place_id, [overview_payload, stats_payload], self.place_type
)
except Exception as e: except Exception as e:
last_error = e logger.error(f"[NvMapScraper] 브라우저 폴백 오류: {e}")
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}") raise GraphQLException(f"브라우저 폴백 실패: {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 not results or results[0] is None or "data" not in results[0]:
if last_error:
raise GraphQLException(f"브라우저 폴백 실패: {last_error}")
raise GraphQLException("브라우저 폴백 크롤링 실패 (WTM 토큰 또는 응답 없음)") raise GraphQLException("브라우저 폴백 크롤링 실패 (WTM 토큰 또는 응답 없음)")
data = results[0] data = results[0]
stats_data = None stats_data = None
stats_raw = results[1] if len(results) > 1 else None stats_raw = results[1] if len(results) > 1 else None
if stats_raw: if stats_raw:
_vrs = (stats_raw.get("data") or {}).get("visitorReviewStats") or {} stats_data = (
_analysis = _vrs.get("analysis") or {} stats_raw.get("data", {})
_voted = _analysis.get("votedKeyword") or {} .get("visitorReviewStats", {})
stats_data = _voted.get("details") or None .get("analysis", {})
.get("votedKeyword", {})
interior_urls = self._extract_photo_viewer_urls(results[2] if len(results) > 2 else None) .get("details")
exterior_urls = self._extract_photo_viewer_urls(results[3] if len(results) > 3 else None) ) or None
review_urls = self._extract_photo_viewer_urls(results[4] if len(results) > 4 else None)
extra_photo_urls = self._interleave(interior_urls, exterior_urls) + review_urls
logger.info(
f"[NvMapScraper] 보충 이미지 - 내부:{len(interior_urls)} 외부:{len(exterior_urls)} 리뷰:{len(review_urls)}"
)
logger.info(f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}") logger.info(f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}")
return data, stats_data, extra_photo_urls return data, stats_data
async def _call_get_accommodation(self, place_id: str) -> dict: async def _call_get_accommodation(self, place_id: str) -> dict:
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다. """GraphQL API를 호출하여 숙소 정보를 가져옵니다.
@ -430,10 +281,14 @@ query getVisitorReviewStats($id: String!) {
logger.warning(f"[NvMapScraper] review stats failed: {response.status}") logger.warning(f"[NvMapScraper] review stats failed: {response.status}")
return None return None
result = await response.json() result = await response.json()
_vrs = (result.get("data") or {}).get("visitorReviewStats") or {} details = (
_analysis = _vrs.get("analysis") or {} result.get("data", {})
_voted = _analysis.get("votedKeyword") or {} .get("visitorReviewStats", {})
return _voted.get("details") or None .get("analysis", {})
.get("votedKeyword", {})
.get("details")
)
return details or None
except Exception as e: except Exception as e:
logger.warning(f"[NvMapScraper] Failed to get review stats: {e}") logger.warning(f"[NvMapScraper] Failed to get review stats: {e}")
return None return None
@ -447,7 +302,7 @@ query getVisitorReviewStats($id: String!) {
Returns: Returns:
편의시설 정보 문자열 또는 None 편의시설 정보 문자열 또는 None
""" """
place_types = ["place", "accommodation", "restaurant"] place_types = ["accommodation", "restaurant"]
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
for place_type in place_types: for place_type in place_types:

View File

@ -1,6 +1,6 @@
import json import json
import re import re
from pydantic import BaseModel, ValidationError from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
from openai import AsyncOpenAI from openai import AsyncOpenAI
@ -131,23 +131,11 @@ class ChatgptService:
}) })
last_error = None last_error = None
for attempt in range(self.max_retries + 1): for attempt in range(self.max_retries + 1):
try:
response = await self.client.beta.chat.completions.parse( response = await self.client.beta.chat.completions.parse(
model=model, model=model,
messages=[{"role": "user", "content": content}], messages=[{"role": "user", "content": content}],
response_format=output_format response_format=output_format
) )
except (ValidationError, json.JSONDecodeError) as e:
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
# 확률적 출력 문제일 수 있으므로 재시도 대상에 포함한다.
logger.warning(
f"[ChatgptService({self.model_type})] Structured output parse failed "
f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}"
)
last_error = ChatGPTResponseError("parse_error", type(e).__name__, str(e))
if attempt < self.max_retries:
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
continue
# Response 디버그 로깅 # Response 디버그 로깅
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}") logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}") logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
@ -188,7 +176,7 @@ class ChatgptService:
input_data : dict, input_data : dict,
img_url : Optional[str] = None, img_url : Optional[str] = None,
img_detail_high : bool = False, img_detail_high : bool = False,
silent : bool = True silent : bool = False
) -> BaseModel: ) -> BaseModel:
prompt_text = prompt.build_prompt(input_data, silent) prompt_text = prompt.build_prompt(input_data, silent)

View File

@ -3,7 +3,6 @@ from typing import List, Optional
from enum import StrEnum, auto from enum import StrEnum, auto
class SpaceType(StrEnum): class SpaceType(StrEnum):
"""이미지가 촬영된 공간·장소 유형. LLM이 이미지를 보고 해당하는 공간 태그를 선택하는 데 사용."""
exterior_front = auto() exterior_front = auto()
exterior_night = auto() exterior_night = auto()
exterior_aerial = auto() exterior_aerial = auto()
@ -44,7 +43,6 @@ class SpaceType(StrEnum):
detail_tableware = auto() detail_tableware = 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()
@ -57,7 +55,6 @@ class Subject(StrEnum):
person = auto() person = auto()
class Camera(StrEnum): class Camera(StrEnum):
"""이미지의 촬영 기법·구도·조명 특성. 영상 편집 시 컷의 시각적 스타일을 구분하는 태그."""
wide_angle = auto() wide_angle = auto()
tight_crop = auto() tight_crop = auto()
panoramic = auto() panoramic = auto()
@ -71,7 +68,6 @@ class Camera(StrEnum):
has_face = auto() has_face = auto()
class MotionRecommended(StrEnum): class MotionRecommended(StrEnum):
"""해당 이미지에 적용 가능한 카메라 모션 유형. 영상 제작 시 각 컷에 어울리는 움직임을 추천하는 데 사용."""
static = auto() static = auto()
slow_pan = auto() slow_pan = auto()
slow_zoom_in = auto() slow_zoom_in = auto()
@ -80,41 +76,23 @@ 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). 통합 프롬프트가 이 값으로 내부 분기") industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 통합 프롬프트가 이 값으로 내부 분기")
space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트") space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트")
@ -122,18 +100,9 @@ class ImageTagPromptInput(BaseModel):
camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트") camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트")
motion_recommended: list[str] = Field(list(MotionRecommended), description="가능한 카메라 모션 리스트") motion_recommended: list[str] = Field(list(MotionRecommended), description="가능한 카메라 모션 리스트")
class MarketingFilterOutput(BaseModel):
"""크롤링 단계에서 이미지의 마케팅(광고 영상) 사용 가능 여부만 판정하는 프롬프트의 출력 스키마.
ImageTagPromptOutput과 달리 태깅(공간/피사체/카메라/모션/내러티브) 포함하지 않는다."""
marketing_acceptable: bool = Field(..., description="광고 영상 사용 가능 여부")
reject_reason: str = Field(default="", description="거부 사유 (marketing_acceptable=false 시). 예: 저화질, 인물 신원 노출, 업종 부적합, 민감/의료 장면, 미성년자 식별 노출")
# Output 정의 # Output 정의
class ImageTagPromptOutput(BaseModel): class ImageTagPromptOutput(BaseModel):
"""이미지 태깅 프롬프트의 LLM 출력 스키마. #ad_avaliable : bool = Field(..., description="광고 영상 사용 가능 이미지 여부")
공간·피사체·카메라·모션 태그와 내러티브 적합도 점수를 담아 반환.
마케팅 사용 가능 여부는 크롤링 단계(MarketingFilterOutput)에서 이미 판정되므로 포함하지 않는다."""
space_type: list[SpaceType] = Field(..., description="공간적 정보를 가지는 태그 리스트") space_type: list[SpaceType] = Field(..., description="공간적 정보를 가지는 태그 리스트")
subject: list[Subject] = Field(..., description="피사체 정보를 가지는 태그 리스트") subject: list[Subject] = Field(..., description="피사체 정보를 가지는 태그 리스트")
camera: list[Camera] = Field(..., description="카메라 정보를 가지는 태그 리스트") camera: list[Camera] = Field(..., description="카메라 정보를 가지는 태그 리스트")

View File

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

View File

@ -114,7 +114,7 @@ class SunoService:
Args: Args:
prompt: 가사 (customMode=true일 가사로 사용) prompt: 가사 (customMode=true일 가사로 사용)
1 이내 길이의 노래에 적합한 가사여야 1 이내 길이의 노래에 적합한 가사여야
genre: 음악 장르 (: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM", "Rock", "Jazz") genre: 음악 장르 (: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM")
None일 경우 style 파라미터를 전송하지 않음 None일 경우 style 파라미터를 전송하지 않음
callback_url: 생성 완료 알림 받을 URL (None일 경우 config에서 기본값 사용) callback_url: 생성 완료 알림 받을 URL (None일 경우 config에서 기본값 사용)
instrumental: True이면 BGM 전용 더미 가사로 60 길이를 유도하고 보컬 없이 생성 instrumental: True이면 BGM 전용 더미 가사로 60 길이를 유도하고 보컬 없이 생성
@ -137,7 +137,7 @@ class SunoService:
logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨") logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨")
else: else:
formatted_prompt = ( formatted_prompt = (
f"[Song Duration: Around 1 minute - Must be around 60 seconds]\n{prompt}" f"[Song Duration: Exactly 1 minute - Must be around 60 seconds]\n{prompt}"
) )
payload: dict[str, Any] = { payload: dict[str, Any] = {

View File

@ -26,12 +26,13 @@ from app.dependencies.pagination import PaginationParams, get_pagination_params
from app.user.dependencies.auth import get_current_user, get_current_user_optional from app.user.dependencies.auth import get_current_user, get_current_user_optional
from app.user.models import User from app.user.models import User
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
from app.home.models import Image, Project, MarketingIntel from app.home.models import Image, Project, MarketingIntel, ImageTag
from app.home.api.routers.v1.home import _extract_region_from_address from app.home.api.routers.v1.home import _extract_region_from_address
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp from app.song.models import Song, SongTimestamp
from app.utils.creatomate import CreatomateService from app.utils.creatomate import CreatomateService
from app.utils.subtitles import SubtitleContentsGenerator
from app.comment.models import Comment from app.comment.models import Comment
from app.database.like_cache import ( from app.database.like_cache import (
@ -58,7 +59,7 @@ from app.video.schemas.video_schema import (
VideoThumbnailItem, VideoThumbnailItem,
) )
from app.video.worker.video_task import download_and_upload_video_to_blob from app.video.worker.video_task import download_and_upload_video_to_blob
from app.video.services.video import get_image_tags_by_task_id
from config import creatomate_settings from config import creatomate_settings
@ -198,22 +199,16 @@ async def generate_video(
) )
marketing_intelligence: MarketingIntel = marketing_result.scalar_one_or_none() marketing_intelligence: MarketingIntel = marketing_result.scalar_one_or_none()
# 자막 + 이미지 배정 미완료 시 즉시 반환 — Lyric/Song/Image 쿼리 전에 체크하여 불필요한 조회 방지 # subtitle 미완료 시 즉시 반환 — Lyric/Song/Image 쿼리 전에 체크하여 불필요한 조회 방지
# 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도 # 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도
if not marketing_intelligence.subtitle or not marketing_intelligence.image_match:
pending_what = []
if not marketing_intelligence.subtitle: if not marketing_intelligence.subtitle:
pending_what.append("자막") logger.info(f"[generate_video] subtitle pending - task_id: {task_id}")
if not marketing_intelligence.image_match:
pending_what.append("이미지 배정")
pending_msg = ", ".join(pending_what)
logger.info(f"[generate_video] 사전 준비 미완료 ({pending_msg}) - task_id: {task_id}")
return GenerateVideoResponse( return GenerateVideoResponse(
success=False, success=False,
status="subtitle_pending", status="subtitle_pending",
task_id=task_id, task_id=task_id,
creatomate_render_id=None, creatomate_render_id=None,
message=f"{pending_msg} 생성이 아직 완료되지 않았습니다. /lyric/subtitle/status/{{task_id}}로 완료 확인 후 재요청하세요.", message="자막 생성이 아직 완료되지 않았습니다. /lyric/subtitle/status/{task_id}로 완료 확인 후 재요청하세요.",
error_message=None, error_message=None,
) )
@ -370,15 +365,25 @@ async def generate_video(
logger.debug(f"[generate_video] Template fetched - task_id: {task_id}") logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
# 6-2. elements에서 리소스 매핑 생성 # 6-2. elements에서 리소스 매핑 생성
# 이미지 배정은 /lyric/generate 사전 단계에서 이미 수행되어 marketing_intelligence.image_match에 저장됨. # modifications = creatomate_service.elements_connect_resource_blackbox(
# 여기서는 사전 산출물을 읽어 음악 URL과 주소만 보완한다. # elements=template["source"]["elements"],
modifications: dict = dict(marketing_intelligence.image_match) # 슬롯→image_url 사전 배정 결과 # image_url_list=image_urls,
modifications["audio-music"] = music_url # music_url=music_url,
# address_input 슬롯은 사전 단계에서도 채워지지만 영상 시점에 재확인 # address=store_address
for key in list(modifications.keys()): taged_image_list = await get_image_tags_by_task_id(task_id)
if "address_input" in key: min_image_num = creatomate_service.counting_component(
modifications[key] = store_address template = template,
logger.info(f"[generate_video] image_match loaded from DB (slots: {len(modifications)}) - task_id: {task_id}") target_template_type = "image"
)
duplicate = bool(len(taged_image_list) < min_image_num)
logger.info(f"[generate_video] Duplicate : {duplicate} | length of taged_image {len(taged_image_list)}, min_len {min_image_num},- task_id: {task_id}")
modifications = creatomate_service.template_matching_taged_image(
template = template,
taged_image_list = taged_image_list,
music_url = music_url,
address = store_address,
duplicate = duplicate,
)
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}") logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
subtitle_modifications = marketing_intelligence.subtitle subtitle_modifications = marketing_intelligence.subtitle
@ -386,14 +391,14 @@ async def generate_video(
modifications.update(subtitle_modifications) modifications.update(subtitle_modifications)
# revert thumbnail scene # revert thumbnail scene
thumbnail_modifications = creatomate_service.make_thumbnail_modification( # thumbnail_modifications = creatomate_service.make_thumbnail_modification(
brand_name =brand_name, # brand_name =brand_name,
region = region, # region = region,
brand_concept = brand_concept, # brand_concept = brand_concept,
category_definition= category_definition, # category_definition= category_definition,
target_keywords=target_keywords) # target_keywords=target_keywords)
modifications.update(thumbnail_modifications) # modifications.update(thumbnail_modifications)
# 6-3. elements 수정 # 6-3. elements 수정
new_elements = creatomate_service.modify_element( new_elements = creatomate_service.modify_element(

View File

@ -1,218 +0,0 @@
"""
Creative Assets Background Tasks
영상 생성 사전 준비(이미지 배정 + 자막 생성) 수행하는 백그라운드 태스크를 정의합니다.
"""
from sqlalchemy import select
from app.database.session import BackgroundSessionLocal
from app.home.models import Project, MarketingIntel
from app.utils.subtitles import SubtitleContentsGenerator
from app.utils.creatomate import CreatomateService
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:
creatomate_service = CreatomateService(orientation=orientation)
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
# ── 프로젝트 / 마케팅 인텔 로드 ──────────────────────────────────
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_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}"
)
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,
)
# 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,
)
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
subtitle_modifications = {
pitching_output.pitching_tag: 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}")
def _build_pitching_list_with_image_context(
pitching_label_list: list[str],
assigned_image_tags: dict[str, dict],
) -> list[str]:
"""pitching 레이블 리스트에 배정 이미지 컨텍스트를 포함하여 반환합니다.
슬롯명(`bedroom-furniture-tight_crop-slow_zoom_in-welcome`) 5번째 토큰과
pitching 레이어명(`subtitle-welcome-emotion_cue-empathic-001`) 2번째 토큰이
동일한 narrative_phase이므로 시간 겹침 계산 없이 phase로 직접 매칭합니다.
출력 예시:
"subtitle-welcome-emotion_cue-empathic-001 | 화면: lobby / furniture / 진입(welcome) 단계"
"""
# narrative_phase → 해당 phase에 배정된 image_tag 매핑 구성
# 슬롯명 형식: {space_type}-{subject}-{camera}-{motion}-{narrative_phase}
phase_to_tag: dict[str, dict] = {}
for slot_name, tag in assigned_image_tags.items():
tokens = slot_name.split("-")
if len(tokens) >= 5:
phase = tokens[4]
# 같은 phase에 슬롯이 여러 개면 첫 번째만 사용
if phase not in phase_to_tag:
phase_to_tag[phase] = tag
if not phase_to_tag:
return pitching_label_list
result = []
for label in pitching_label_list:
# pitching 레이어명 형식: {track_role}-{narrative_phase}-{content_type}-{tone}-{pair_id}
tokens = label.split("-")
context_str = ""
if len(tokens) >= 2:
phase = tokens[1]
tag = phase_to_tag.get(phase)
if tag:
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 "?"
phase_str = NARRATIVE_PHASE_LABEL.get(phase, phase)
context_str = f" | 화면: {space_str} / {subject_str} / {phase_str} 단계"
result.append(label + context_str)
return result

View File

@ -8,5 +8,4 @@ ALTER TABLE project
ADD COLUMN biz_type VARCHAR(50) NULL DEFAULT 'place' ADD COLUMN biz_type VARCHAR(50) NULL DEFAULT 'place'
COMMENT '업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)' AFTER id; COMMENT '업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)' AFTER id;
ALTER TABLE project CHANGE biz_type industry VARCHAR(50) DEFAULT '' ALTER TABLE project CHANGE biz_type industry VARCHAR(50) DEFAULT '';
COMMENT '업종 분류' AFTER id;

View File

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

View File

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