언어선택에 따른 자막생성 구현

feature-subtitle
김성경 2026-06-08 09:20:27 +09:00
parent bdee852bed
commit 997927d42a
10 changed files with 149 additions and 45 deletions

View File

@ -269,15 +269,6 @@ 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: 서비스 초기화 및 프롬프트 생성...")
promotional_expressions = {
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
"Chinese" : "网红打卡, 治愈系, 旅行, 度假, 拍照圣地",
"Japanese" : "インスタ映え, 写真のような一日, 癒し, 旅行, 絶景",
"Thai" : "ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย",
"Vietnamese" : "check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp"
}# HARD CODED, 어디에 정리하지? 아직 정리되지 않음
timing_rules = { timing_rules = {
"60s" : """ "60s" : """
812 lines 812 lines
@ -294,7 +285,6 @@ 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,
"promotional_expression_example" : promotional_expressions[request_body.language],
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나 "timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
} }

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

@ -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

@ -93,6 +93,9 @@ query getAccommodation($id: String!, $deviceType: String) {
raise URLNotFoundException("This URL does not contain a place ID") raise URLNotFoundException("This URL does not contain a place ID")
match = re.search(place_pattern, self.url) match = re.search(place_pattern, self.url)
if not match:
# place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161)
match = re.search(r"place\.naver\.com/[a-zA-Z]+/(\d+)", self.url)
if not match: if not match:
raise URLNotFoundException("Failed to parse place ID from URL") raise URLNotFoundException("Failed to parse place ID from URL")
return match[1] return match[1]

View File

@ -1,4 +1,6 @@
import os
import gspread import gspread
from pathlib import Path
from pydantic import BaseModel from pydantic import BaseModel
from google.oauth2.service_account import Credentials from google.oauth2.service_account import Credentials
from config import prompt_settings from config import prompt_settings
@ -34,15 +36,24 @@ class Prompt():
prompt_input_class = BaseModel prompt_input_class = BaseModel
prompt_output_class = BaseModel prompt_output_class = BaseModel
def __init__(self, sheet_name, prompt_input_class, prompt_output_class): def __init__(self, sheet_name, prompt_input_class, prompt_output_class, template_file: str = None, prompt_model: str = None):
self.sheet_name = sheet_name self.sheet_name = sheet_name
self.prompt_input_class = prompt_input_class self.prompt_input_class = prompt_input_class
self.prompt_output_class = prompt_output_class self.prompt_output_class = prompt_output_class
self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name) if template_file:
self.prompt_template = Path(template_file).read_text(encoding="utf-8")
self.prompt_model = prompt_model
self._template_file = template_file
else:
self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name)
self._template_file = None
def _reload_prompt(self): def _reload_prompt(self):
_sheet_cache.pop(self.sheet_name, None) if self._template_file:
self.prompt_template, self.prompt_model = _read_sheet_data(self.sheet_name) self.prompt_template = Path(self._template_file).read_text(encoding="utf-8")
else:
_sheet_cache.pop(self.sheet_name, None)
self.prompt_template, self.prompt_model = _read_sheet_data(self.sheet_name)
def build_prompt(self, input_data:dict, silent:bool = False) -> str: def build_prompt(self, input_data:dict, silent:bool = False) -> str:
verified_input = self.prompt_input_class(**input_data) verified_input = self.prompt_input_class(**input_data)
@ -77,12 +88,17 @@ image_autotag_prompt = Prompt(
prompt_output_class=ImageTagPromptOutput, prompt_output_class=ImageTagPromptOutput,
) )
_SUBTITLE_TEMPLATE_PATH = str(Path(__file__).parent / "templates" / "subtitle_prompt.txt")
@lru_cache() @lru_cache()
def create_dynamic_subtitle_prompt(length: int) -> Prompt: def create_dynamic_subtitle_prompt(length: int) -> Prompt:
return Prompt( return Prompt(
sheet_name="subtitle", sheet_name="subtitle",
prompt_input_class=SubtitlePromptInput, prompt_input_class=SubtitlePromptInput,
prompt_output_class=SubtitlePromptOutput[length], prompt_output_class=SubtitlePromptOutput[length],
template_file=_SUBTITLE_TEMPLATE_PATH,
prompt_model=os.getenv("SUBTITLE_PROMPT_MODEL", "gpt-4o-mini"),
) )

View File

@ -8,7 +8,6 @@ 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 = "가사 언어")
promotional_expression_example : str = Field(..., description = "판촉 가사 표현 예시")
timing_rules : str = Field(..., description = "시간 제어문") timing_rules : str = Field(..., description = "시간 제어문")
# Output 정의 # Output 정의

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
@ -231,3 +238,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