Merge branch 'feature-subtitle'

feature-imageMatch
김성경 2026-06-12 16:18:30 +09:00
commit d1fe7fcd5f
9 changed files with 170 additions and 34 deletions

View File

@ -268,7 +268,7 @@ async def generate_lyric(
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ========== # ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
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 = { timing_rules = {
"60s" : """ "60s" : """
812 lines 812 lines

View File

@ -186,13 +186,15 @@ async def generate_subtitle_background(
store_address = project.detail_region_info store_address = project.detail_region_info
customer_name = project.store_name 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( generated_subtitles = await subtitle_generator.generate_subtitle_contents(
marketing_intelligence=marketing_intelligence.intel_result, marketing_intelligence=marketing_intelligence.intel_result,
pitching_label_list=pitchings, pitching_label_list=pitchings,
customer_name=customer_name, customer_name=customer_name,
detail_region_info=store_address, detail_region_info=store_address,
language=language,
) )
pitching_output_list = generated_subtitles.pitching_results pitching_output_list = generated_subtitles.pitching_results

View File

@ -56,6 +56,8 @@ class SocialUploadResponse(BaseModel):
platform: str = Field(..., description="플랫폼명") platform: str = Field(..., description="플랫폼명")
status: str = Field(..., description="업로드 상태") status: str = Field(..., description="업로드 상태")
message: str = Field(..., description="응답 메시지") message: str = Field(..., description="응답 메시지")
scheduled_at: Optional[datetime] = Field(None, description="예약 시간 (예약 업로드 충돌 시 반환)")
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
@ -65,6 +67,7 @@ class SocialUploadResponse(BaseModel):
"platform": "youtube", "platform": "youtube",
"status": "pending", "status": "pending",
"message": "업로드 요청이 접수되었습니다.", "message": "업로드 요청이 접수되었습니다.",
"scheduled_at": "2026-02-02T15:00:00",
} }
} }
) )

View File

@ -10,7 +10,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import BackgroundTasks, HTTPException, status from fastapi import BackgroundTasks, HTTPException, status
from sqlalchemy import func, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import TIMEZONE from config import TIMEZONE
@ -90,12 +90,19 @@ class SocialUploadService:
) )
raise SocialAccountNotFoundError() 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( in_progress_result = await session.execute(
select(SocialUpload).where( select(SocialUpload).where(
SocialUpload.video_id == body.video_id, SocialUpload.video_id == body.video_id,
SocialUpload.social_account_id == account.id, SocialUpload.social_account_id == account.id,
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]), 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() in_progress_upload = in_progress_result.scalar_one_or_none()
@ -112,6 +119,32 @@ class SocialUploadService:
message="이미 업로드가 진행 중입니다.", 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. 업로드 순번 계산 # 4. 업로드 순번 계산
max_seq_result = await session.execute( max_seq_result = await session.execute(
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where( select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
@ -153,7 +186,6 @@ class SocialUploadService:
) )
# 6. 즉시 업로드이면 백그라운드 태스크 등록 # 6. 즉시 업로드이면 백그라운드 태스크 등록
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive
if not is_scheduled: if not is_scheduled:
background_tasks.add_task(process_social_upload, social_upload.id) background_tasks.add_task(process_social_upload, social_upload.id)

View File

@ -4,15 +4,24 @@ BGM 모드용 더미 가사 템플릿
instrumental=True 호출 Suno가 가사 길이/구조를 참고해 60초짜리 BGM을 생성하도록 instrumental=True 호출 Suno가 가사 길이/구조를 참고해 60초짜리 BGM을 생성하도록
placeholder 가사를 제공합니다. 실제 보컬은 생성되지 않습니다. placeholder 가사를 제공합니다. 실제 보컬은 생성되지 않습니다.
3가지 버전 모두 섹션 태그 없이 한국어 9줄로 통일. 장르 순서: K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz
분위기(밝음/감성/에너지) 가사 텍스트로 차별화합니다. 섹션 태그 없이 한국어 10줄로 구성.
""" """
import random _BGM_DUMMY_LYRICS: dict[str, str] = {
"K-Pop": (
_BGM_DUMMY_LYRICS_LIST = [ "반짝이는 눈빛으로 날 바라봐\n"
# 버전 1 — 밝고 경쾌한 분위기 "심장이 터질 것 같은 이 느낌\n"
( "너만 보면 세상이 달라 보여\n"
"오늘도 설레임이 멈추질 않아\n"
"같이 걸어가는 이 길 위에서\n"
"우리 둘만의 노래가 흘러\n"
"빛나는 순간들을 모아모아\n"
"영원히 기억할 우리의 이야기\n"
"지금 이 순간 네 곁에 있을게\n"
"함께라면 뭐든 할 수 있어\n"
),
"Pop": (
"햇살 가득한 아침이 시작되고\n" "햇살 가득한 아침이 시작되고\n"
"따스한 바람이 살며시 불어와\n" "따스한 바람이 살며시 불어와\n"
"거리마다 웃음꽃이 피어나고\n" "거리마다 웃음꽃이 피어나고\n"
@ -24,8 +33,31 @@ _BGM_DUMMY_LYRICS_LIST = [
"오늘 하루도 빛나는 하루야\n" "오늘 하루도 빛나는 하루야\n"
"환한 미소로 하루를 마무리해\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" "조용히 흘러가는 시간 속에\n"
"잔잔한 바람이 마음을 적시고\n" "잔잔한 바람이 마음을 적시고\n"
@ -37,8 +69,7 @@ _BGM_DUMMY_LYRICS_LIST = [
"이 순간 여기 머무는 것만으로도 충분해\n" "이 순간 여기 머무는 것만으로도 충분해\n"
"고요한 밤이 나를 감싸 안아줘\n" "고요한 밤이 나를 감싸 안아줘\n"
), ),
# 버전 3 — 강렬하고 에너지 넘치는 분위기 "EDM": (
(
"밤거리에 불빛이 타오르고\n" "밤거리에 불빛이 타오르고\n"
"심장이 두근두근 뛰기 시작해\n" "심장이 두근두근 뛰기 시작해\n"
"온몸에 퍼지는 뜨거운 열기\n" "온몸에 퍼지는 뜨거운 열기\n"
@ -50,14 +81,63 @@ _BGM_DUMMY_LYRICS_LIST = [
"끝까지 불태워 이 에너지를\n" "끝까지 불태워 이 에너지를\n"
"새벽빛이 밝아올 때까지 달려\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]: _GENRE_ALIAS: dict[str, str] = {
"""BGM 더미 가사 3종 중 하나를 랜덤으로 반환합니다. "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: Returns:
(lyrics, version): 선택된 가사 텍스트와 버전 번호 (1~3) 선택된 장르의 가사 텍스트
""" """
index = random.randrange(len(_BGM_DUMMY_LYRICS_LIST)) return _BGM_DUMMY_LYRICS[normalize_genre(genre)]
return _BGM_DUMMY_LYRICS_LIST[index], index + 1

View File

@ -1,16 +1,16 @@
from pydantic import BaseModel, create_model, Field from pydantic import BaseModel, create_model, Field
from typing import List, Optional from typing import List
from functools import lru_cache from functools import lru_cache
# Input 정의 # Input 정의
class SubtitlePromptInput(BaseModel): class SubtitlePromptInput(BaseModel):
marketing_intelligence : str = Field(..., description="마케팅 인텔리전스 정보") marketing_intelligence : str = Field(..., description="마케팅 인텔리전스 정보")
pitching_tag_list_string : str = Field(..., description="필요한 피칭 레이블 리스트 stringify") pitching_tag_list_string : str = Field(..., description="필요한 피칭 레이블 리스트 stringify")
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름") customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
language : str = Field(default="Korean", description="자막 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)")
#subtillecars :
# Output 정의 # Output 정의
class PitchingOutput(BaseModel): class PitchingOutput(BaseModel):
pitching_tag: str = Field(..., description="피칭 레이블") pitching_tag: str = Field(..., description="피칭 레이블")

View File

@ -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. 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 ### 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. 1. NEVER copy JSON verbatim. ALWAYS rewrite into video-optimized copy.
2. NEVER invent facts not in the data. You MAY freely transform expressions. 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. 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: 비즈니스 정보 ** **입력 3: 비즈니스 정보 **
Business Name: {customer_name} Business Name: {customer_name}
Region Details: {detail_region_info} Region Details: {detail_region_info}
**입력 4: 출력 언어**
Language: {language}

View File

@ -16,22 +16,24 @@ class SubtitleContentsGenerator():
def __init__(self): def __init__(self):
self.chatgpt_service = ChatgptService(timeout=60.0) self.chatgpt_service = ChatgptService(timeout=60.0)
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str) -> 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() start = time.perf_counter()
logger.info( logger.info(
f"[SubtitleContentsGenerator] START - customer: {customer_name}, " f"[SubtitleContentsGenerator] START - customer: {customer_name}, "
f"pitching_count: {len(pitching_label_list)}, " f"pitching_count: {len(pitching_label_list)}, "
f"labels: {pitching_label_list}" f"labels: {pitching_label_list}, "
f"language: {language}"
) )
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list)) dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list))
pitching_label_string = "\n".join(pitching_label_list) pitching_label_string = "\n".join(pitching_label_list)
marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False) marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False)
input_data = { input_data = {
"marketing_intelligence" : marketing_intel_string , "marketing_intelligence" : marketing_intel_string,
"pitching_tag_list_string" : pitching_label_string, "pitching_tag_list_string" : pitching_label_string,
"customer_name" : customer_name, "customer_name" : customer_name,
"detail_region_info" : detail_region_info, "detail_region_info" : detail_region_info,
"language" : language,
} }
logger.info( logger.info(

View File

@ -60,7 +60,7 @@ from typing import Any
import httpx import httpx
from app.song.schemas.song_schema import PollingSongResponse, SongClipData 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 app.utils.logger import get_logger
from config import apikey_settings, recovery_settings from config import apikey_settings, recovery_settings
@ -129,10 +129,12 @@ class SunoService:
""" """
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
normalized_genre = _normalize_genre(genre) if genre else None
if instrumental: 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}" 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: else:
formatted_prompt = ( formatted_prompt = (
f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}" f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}"
@ -145,8 +147,8 @@ class SunoService:
"prompt": formatted_prompt, "prompt": formatted_prompt,
"callBackUrl": actual_callback_url, "callBackUrl": actual_callback_url,
} }
if genre: if normalized_genre:
payload["style"] = f"{genre}, around 60 seconds" if instrumental else genre payload["style"] = f"{normalized_genre}, around 60 seconds" if instrumental else normalized_genre
last_error: Exception | None = None last_error: Exception | None = None