Compare commits
2 Commits
bdee852bed
...
bea0fa0684
| Author | SHA1 | Date |
|---|---|---|
|
|
bea0fa0684 | |
|
|
997927d42a |
|
|
@ -269,15 +269,6 @@ async def generate_lyric(
|
|||
step1_start = time.perf_counter()
|
||||
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 = {
|
||||
"60s" : """
|
||||
8–12 lines
|
||||
|
|
@ -294,7 +285,6 @@ async def generate_lyric(
|
|||
"detail_region_info" : request_body.detail_region_info or "",
|
||||
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
||||
"language" : request_body.language,
|
||||
"promotional_expression_example" : promotional_expressions[request_body.language],
|
||||
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||
raise URLNotFoundException("This URL does not contain a place ID")
|
||||
|
||||
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:
|
||||
raise URLNotFoundException("Failed to parse place ID from URL")
|
||||
return match[1]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ class LyricPromptInput(BaseModel):
|
|||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
||||
language : str= Field(..., description = "가사 언어")
|
||||
promotional_expression_example : str = Field(..., description = "판촉 가사 표현 예시")
|
||||
timing_rules : str = Field(..., description = "시간 제어문")
|
||||
|
||||
# Output 정의
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from pydantic import BaseModel, create_model, Field
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
from functools import lru_cache
|
||||
|
||||
# Input 정의
|
||||
|
|
@ -9,8 +9,8 @@ class SubtitlePromptInput(BaseModel):
|
|||
pitching_tag_list_string : str = Field(..., description="필요한 피칭 레이블 리스트 stringify")
|
||||
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||
language : str = Field(default="Korean", description="자막 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)")
|
||||
|
||||
#subtillecars :
|
||||
# Output 정의
|
||||
class PitchingOutput(BaseModel):
|
||||
pitching_tag: str = Field(..., description="피칭 레이블")
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue