diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index ff7b250..c19b975 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -268,7 +268,7 @@ async def generate_lyric( # ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ========== step1_start = time.perf_counter() logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...") - + timing_rules = { "60s" : """ 8–12 lines diff --git a/app/lyric/worker/lyric_task.py b/app/lyric/worker/lyric_task.py index dd0620c..fa84303 100644 --- a/app/lyric/worker/lyric_task.py +++ b/app/lyric/worker/lyric_task.py @@ -186,13 +186,15 @@ async def generate_subtitle_background( store_address = project.detail_region_info customer_name = project.store_name - logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, store_address: {store_address}") + 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 diff --git a/app/social/schemas/upload_schema.py b/app/social/schemas/upload_schema.py index d095262..d33e167 100644 --- a/app/social/schemas/upload_schema.py +++ b/app/social/schemas/upload_schema.py @@ -56,6 +56,8 @@ class SocialUploadResponse(BaseModel): platform: str = Field(..., description="플랫폼명") status: str = Field(..., description="업로드 상태") message: str = Field(..., description="응답 메시지") + scheduled_at: Optional[datetime] = Field(None, description="예약 시간 (예약 업로드 충돌 시 반환)") + model_config = ConfigDict( json_schema_extra={ @@ -65,6 +67,7 @@ class SocialUploadResponse(BaseModel): "platform": "youtube", "status": "pending", "message": "업로드 요청이 접수되었습니다.", + "scheduled_at": "2026-02-02T15:00:00", } } ) diff --git a/app/social/services/upload_service.py b/app/social/services/upload_service.py index dec2d88..a2da831 100644 --- a/app/social/services/upload_service.py +++ b/app/social/services/upload_service.py @@ -10,7 +10,7 @@ from datetime import datetime from typing import Optional from fastapi import BackgroundTasks, HTTPException, status -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from config import TIMEZONE @@ -90,12 +90,19 @@ class SocialUploadService: ) raise SocialAccountNotFoundError() - # 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만) + # 3. 중복 업로드 확인 + now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None) + + # 3-1. 진행 중인 업로드 확인 (즉시 pending 또는 uploading) in_progress_result = await session.execute( select(SocialUpload).where( SocialUpload.video_id == body.video_id, SocialUpload.social_account_id == account.id, SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]), + or_( + SocialUpload.scheduled_at.is_(None), + SocialUpload.scheduled_at <= now_kst_naive, + ), ) ) in_progress_upload = in_progress_result.scalar_one_or_none() @@ -112,6 +119,32 @@ class SocialUploadService: message="이미 업로드가 진행 중입니다.", ) + # 3-2. 미래 예약 업로드 확인 + scheduled_result = await session.execute( + select(SocialUpload).where( + SocialUpload.video_id == body.video_id, + SocialUpload.social_account_id == account.id, + SocialUpload.status == UploadStatus.PENDING.value, + SocialUpload.scheduled_at.isnot(None), + SocialUpload.scheduled_at > now_kst_naive, + ) + ) + scheduled_upload = scheduled_result.scalar_one_or_none() + + if scheduled_upload: + logger.info( + f"[UPLOAD_SERVICE] 예약된 업로드 존재 - " + f"upload_id: {scheduled_upload.id}, scheduled_at: {scheduled_upload.scheduled_at}" + ) + return SocialUploadResponse( + success=False, + upload_id=scheduled_upload.id, + platform=account.platform, + status=scheduled_upload.status, + message="이미 예약된 업로드가 있습니다.", + scheduled_at=scheduled_upload.scheduled_at, + ) + # 4. 업로드 순번 계산 max_seq_result = await session.execute( select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where( @@ -153,7 +186,6 @@ class SocialUploadService: ) # 6. 즉시 업로드이면 백그라운드 태스크 등록 - now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None) is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive if not is_scheduled: background_tasks.add_task(process_social_upload, social_upload.id) diff --git a/app/utils/bgm_lyrics.py b/app/utils/bgm_lyrics.py index fa8bc20..77d0338 100644 --- a/app/utils/bgm_lyrics.py +++ b/app/utils/bgm_lyrics.py @@ -4,15 +4,24 @@ BGM 모드용 더미 가사 템플릿 instrumental=True 호출 시 Suno가 가사 길이/구조를 참고해 60초짜리 BGM을 생성하도록 placeholder 가사를 제공합니다. 실제 보컬은 생성되지 않습니다. -3가지 버전 모두 섹션 태그 없이 한국어 9줄로 통일. -분위기(밝음/감성/에너지)만 가사 텍스트로 차별화합니다. +장르 순서: K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz +섹션 태그 없이 한국어 10줄로 구성. """ -import random - -_BGM_DUMMY_LYRICS_LIST = [ - # 버전 1 — 밝고 경쾌한 분위기 - ( +_BGM_DUMMY_LYRICS: dict[str, str] = { + "K-Pop": ( + "반짝이는 눈빛으로 날 바라봐\n" + "심장이 터질 것 같은 이 느낌\n" + "너만 보면 세상이 달라 보여\n" + "오늘도 설레임이 멈추질 않아\n" + "같이 걸어가는 이 길 위에서\n" + "우리 둘만의 노래가 흘러\n" + "빛나는 순간들을 모아모아\n" + "영원히 기억할 우리의 이야기\n" + "지금 이 순간 네 곁에 있을게\n" + "함께라면 뭐든 할 수 있어\n" + ), + "Pop": ( "햇살 가득한 아침이 시작되고\n" "따스한 바람이 살며시 불어와\n" "거리마다 웃음꽃이 피어나고\n" @@ -24,8 +33,31 @@ _BGM_DUMMY_LYRICS_LIST = [ "오늘 하루도 빛나는 하루야\n" "환한 미소로 하루를 마무리해\n" ), - # 버전 2 — 잔잔하고 감성적인 분위기 - ( + "R&B": ( + "부드럽게 흐르는 이 그루브에\n" + "몸이 저절로 리듬을 타기 시작해\n" + "네 목소리가 귓가에 맴돌고\n" + "이 감각이 온몸을 감싸줘\n" + "달콤한 밤이 깊어질수록\n" + "너와 나 사이 거리가 좁혀져\n" + "촛불처럼 은은하게 타오르는\n" + "이 감정을 숨길 수가 없어\n" + "부드럽게 내 손을 잡아줘\n" + "오늘 밤 우리만의 노래를 불러\n" + ), + "Hip-Hop": ( + "내 방식대로 살아가는 이 길\n" + "누가 뭐래도 내 페이스로 가\n" + "매일 아침 눈을 뜨면 새로운 무대\n" + "두려움 없이 앞으로 나아가\n" + "땀과 노력으로 쌓아온 오늘\n" + "포기란 없어 끝까지 밀어붙여\n" + "내 이름을 기억해 두라고\n" + "이 무대 위에 내 발자국 남겨\n" + "하나둘 쌓여가는 내 이야기\n" + "진짜배기는 지금부터 시작이야\n" + ), + "Ballad": ( "저녁 노을이 물드는 창가에서\n" "조용히 흘러가는 시간 속에\n" "잔잔한 바람이 마음을 적시고\n" @@ -37,8 +69,7 @@ _BGM_DUMMY_LYRICS_LIST = [ "이 순간 여기 머무는 것만으로도 충분해\n" "고요한 밤이 나를 감싸 안아줘\n" ), - # 버전 3 — 강렬하고 에너지 넘치는 분위기 - ( + "EDM": ( "밤거리에 불빛이 타오르고\n" "심장이 두근두근 뛰기 시작해\n" "온몸에 퍼지는 뜨거운 열기\n" @@ -50,14 +81,63 @@ _BGM_DUMMY_LYRICS_LIST = [ "끝까지 불태워 이 에너지를\n" "새벽빛이 밝아올 때까지 달려\n" ), -] + "Rock": ( + "굉음을 내며 울려 퍼지는 기타\n" + "온몸을 뒤흔드는 강렬한 비트\n" + "규칙 따위는 집어치우고\n" + "있는 그대로 외쳐봐\n" + "불꽃처럼 타오르는 이 열정\n" + "아무도 막을 수 없어 지금\n" + "거침없이 달려가는 이 무대\n" + "목청껏 소리 질러 자유를\n" + "부서질 듯 뜨겁게 흔들어\n" + "이 밤이 끝날 때까지 록이야\n" + ), + "Jazz": ( + "커피 향이 피어오르는 오후\n" + "느긋하게 흐르는 재즈 선율에\n" + "발끝이 리듬을 타기 시작해\n" + "달콤한 여유가 가득 차오르고\n" + "창밖엔 황금빛 도시가 반짝여\n" + "잔을 들어 이 순간을 건배해\n" + "스윙하는 박자에 몸을 맡기고\n" + "부드럽게 흘러가는 이 밤을\n" + "기억 속에 새겨두고 싶어\n" + "재즈처럼 자유롭게 살고 싶어\n" + ), +} -def get_random_bgm_lyrics() -> tuple[str, int]: - """BGM 더미 가사 3종 중 하나를 랜덤으로 반환합니다. +_GENRE_ALIAS: dict[str, str] = { + "kpop": "K-Pop", + "k-pop": "K-Pop", + "k_pop": "K-Pop", + "pop": "Pop", + "rnb": "R&B", + "r&b": "R&B", + "r_b": "R&B", + "hiphop": "Hip-Hop", + "hip-hop": "Hip-Hop", + "hip_hop": "Hip-Hop", + "ballad": "Ballad", + "edm": "EDM", + "rock": "Rock", + "jazz": "Jazz", +} + + +def normalize_genre(genre: str) -> str: + return _GENRE_ALIAS.get(genre.lower(), genre) + + +def get_bgm_lyrics(genre: str) -> str: + """장르에 맞는 BGM 더미 가사를 반환합니다. + + Args: + genre: 장르명 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz) + 대소문자 및 구분자(-, _) 무관하게 처리됩니다. Returns: - (lyrics, version): 선택된 가사 텍스트와 버전 번호 (1~3) + 선택된 장르의 가사 텍스트 """ - index = random.randrange(len(_BGM_DUMMY_LYRICS_LIST)) - return _BGM_DUMMY_LYRICS_LIST[index], index + 1 + return _BGM_DUMMY_LYRICS[normalize_genre(genre)] diff --git a/app/utils/prompts/schemas/subtitle.py b/app/utils/prompts/schemas/subtitle.py index 3ec94c3..aa991e4 100644 --- a/app/utils/prompts/schemas/subtitle.py +++ b/app/utils/prompts/schemas/subtitle.py @@ -1,16 +1,16 @@ from pydantic import BaseModel, create_model, Field -from typing import List, Optional +from typing import List from functools import lru_cache # Input 정의 class SubtitlePromptInput(BaseModel): - marketing_intelligence : str = Field(..., description="마케팅 인텔리전스 정보") + marketing_intelligence : str = Field(..., description="마케팅 인텔리전스 정보") pitching_tag_list_string : str = Field(..., description="필요한 피칭 레이블 리스트 stringify") customer_name : str = Field(..., description = "마케팅 대상 사업체 이름") detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") - - #subtillecars : + language : str = Field(default="Korean", description="자막 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)") + # Output 정의 class PitchingOutput(BaseModel): pitching_tag: str = Field(..., description="피칭 레이블") diff --git a/app/utils/prompts/templates/subtitle_prompt.txt b/app/utils/prompts/templates/subtitle_prompt.txt index 44f11c5..5b23d0d 100644 --- a/app/utils/prompts/templates/subtitle_prompt.txt +++ b/app/utils/prompts/templates/subtitle_prompt.txt @@ -2,6 +2,13 @@ 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 @@ -9,6 +16,11 @@ You are a subtitle copywriter for hospitality short-form videos. You generate su 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. --- @@ -231,3 +243,6 @@ Keyword pattern analysis: **입력 3: 비즈니스 정보 ** Business Name: {customer_name} Region Details: {detail_region_info} + +**입력 4: 출력 언어** +Language: {language} diff --git a/app/utils/subtitles.py b/app/utils/subtitles.py index 06c6a12..5c6f12a 100644 --- a/app/utils/subtitles.py +++ b/app/utils/subtitles.py @@ -16,22 +16,24 @@ class SubtitleContentsGenerator(): def __init__(self): 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) -> SubtitlePromptOutput: + 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: start = time.perf_counter() logger.info( f"[SubtitleContentsGenerator] START - customer: {customer_name}, " f"pitching_count: {len(pitching_label_list)}, " - f"labels: {pitching_label_list}" + f"labels: {pitching_label_list}, " + f"language: {language}" ) dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list)) pitching_label_string = "\n".join(pitching_label_list) marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False) input_data = { - "marketing_intelligence" : marketing_intel_string , + "marketing_intelligence" : marketing_intel_string, "pitching_tag_list_string" : pitching_label_string, "customer_name" : customer_name, "detail_region_info" : detail_region_info, + "language" : language, } logger.info( diff --git a/app/utils/suno.py b/app/utils/suno.py index 9bd1e1f..e9e809f 100644 --- a/app/utils/suno.py +++ b/app/utils/suno.py @@ -60,7 +60,7 @@ from typing import Any import httpx from app.song.schemas.song_schema import PollingSongResponse, SongClipData -from app.utils.bgm_lyrics import get_random_bgm_lyrics +from app.utils.bgm_lyrics import get_bgm_lyrics, normalize_genre as _normalize_genre from app.utils.logger import get_logger from config import apikey_settings, recovery_settings @@ -129,10 +129,12 @@ class SunoService: """ actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL + normalized_genre = _normalize_genre(genre) if genre else None + if instrumental: - bgm_lyrics, bgm_version = get_random_bgm_lyrics() + bgm_lyrics = get_bgm_lyrics(genre) formatted_prompt = f"[Song Duration: Around 1 minute - Must be around 60 seconds]\n{bgm_lyrics}" - logger.info(f"[Suno] BGM 더미 가사 버전 {bgm_version} 선택됨") + logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨") else: formatted_prompt = ( f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}" @@ -145,8 +147,8 @@ class SunoService: "prompt": formatted_prompt, "callBackUrl": actual_callback_url, } - if genre: - payload["style"] = f"{genre}, around 60 seconds" if instrumental else genre + if normalized_genre: + payload["style"] = f"{normalized_genre}, around 60 seconds" if instrumental else normalized_genre last_error: Exception | None = None