번역오류 수정 및 CTA적용
commit
788e76ef76
|
|
@ -946,6 +946,11 @@ async def tagging_images(
|
||||||
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):
|
||||||
if isinstance(tag_data, Exception):
|
if isinstance(tag_data, Exception):
|
||||||
|
# 태깅 실패 이미지는 img_tag가 NULL로 남아 영상 생성 풀에서 제외됨
|
||||||
|
logger.warning(
|
||||||
|
f"[tagging_images] 이미지 태깅 최종 실패 - url: {tag.img_url}, "
|
||||||
|
f"error: {type(tag_data).__name__}: {tag_data}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
tag.img_tag = tag_data.model_dump(mode="json")
|
tag.img_tag = tag_data.model_dump(mode="json")
|
||||||
session.add(tag)
|
session.add(tag)
|
||||||
|
|
|
||||||
|
|
@ -370,8 +370,10 @@ class ImageTag(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
img_tag: Mapped[dict[str, Any]] = mapped_column(
|
img_tag: Mapped[dict[str, Any]] = mapped_column(
|
||||||
JSON,
|
# none_as_null=True: ORM에서 None 대입 시 JSON 리터럴 null이 아닌 SQL NULL로 저장.
|
||||||
|
# 미지정 시 JSON null이 저장되어 `img_tag.is_not(None)` 필터를 통과하는 버그가 있었음.
|
||||||
|
JSON(none_as_null=True),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
default=False,
|
default=None,
|
||||||
comment="태그 JSON",
|
comment="태그 JSON",
|
||||||
)
|
)
|
||||||
|
|
@ -35,7 +35,7 @@ logger = get_logger("song")
|
||||||
|
|
||||||
router = APIRouter(prefix="/song", tags=["Song"])
|
router = APIRouter(prefix="/song", tags=["Song"])
|
||||||
|
|
||||||
TARGET_SONG_DURATION_SECONDS = 60.0
|
TARGET_SONG_DURATION_SECONDS = 40.0
|
||||||
|
|
||||||
|
|
||||||
def _select_clip_by_duration(
|
def _select_clip_by_duration(
|
||||||
|
|
@ -99,7 +99,7 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
|
||||||
```
|
```
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 생성되는 노래는 약 1분 이내 길이입니다.
|
- 생성되는 노래는 약 40초 내외 길이입니다.
|
||||||
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
||||||
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
||||||
""",
|
""",
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,31 @@ AUDIO_TRACK = 2
|
||||||
SUBTITLE_TRACK = 3
|
SUBTITLE_TRACK = 3
|
||||||
KEYWORD_TRACK = 4
|
KEYWORD_TRACK = 4
|
||||||
|
|
||||||
|
|
||||||
|
def is_fixed_slot_name(name: str) -> bool:
|
||||||
|
"""이름이 '-fixed'로 끝나는 요소인지 판별합니다.
|
||||||
|
|
||||||
|
템플릿 명명 규칙상 마지막 토큰이 'fixed'인 요소(예: 'brand-cta-bi_logo-
|
||||||
|
factual-fixed', 'brand-cta-contact_phone-factual-fixed')는 회사 연락처
|
||||||
|
문구·로고 등 매 영상마다 절대 바뀌면 안 되는 고정 콘텐츠다. 이런 요소는
|
||||||
|
이미지 배정/자막 생성 대상에서 제외하고 템플릿에 이미 설정된 값을 그대로
|
||||||
|
보존해야 한다.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
return name.rsplit("-", 1)[-1] == "fixed"
|
||||||
|
|
||||||
|
# 언어별 대체 폰트 (Google Fonts — Creatomate 기본 지원).
|
||||||
|
# 템플릿 기본 폰트(GyeonggiTitleOTF/Pretendard)는 한글·라틴 전용이라 여기 있는
|
||||||
|
# 언어는 렌더 직전 전체 텍스트 폰트를 교체한다. 매핑에 없는 언어(Korean,
|
||||||
|
# English 등)는 템플릿 원본 폰트를 유지한다. 중국어는 간체(SC) 기준.
|
||||||
|
LANGUAGE_FONT_MAP = {
|
||||||
|
"Japanese": "Noto Sans JP",
|
||||||
|
"Chinese": "Noto Sans SC",
|
||||||
|
"Thai": "Noto Sans Thai",
|
||||||
|
"Vietnamese": "Noto Sans",
|
||||||
|
}
|
||||||
|
|
||||||
def select_template(
|
def select_template(
|
||||||
orientation: OrientationType,
|
orientation: OrientationType,
|
||||||
industry: str | None = None,
|
industry: str | None = None,
|
||||||
|
|
@ -508,13 +533,23 @@ class CreatomateService:
|
||||||
template : dict,
|
template : dict,
|
||||||
target_template_type : str
|
target_template_type : str
|
||||||
) -> list:
|
) -> list:
|
||||||
|
"""target_template_type 요소 개수를 셉니다.
|
||||||
|
|
||||||
|
target_template_type이 "image"인 경우, 고정 자산('-fixed' 명명) 및
|
||||||
|
5토큰 명명 규칙을 따르지 않는 요소(고정 워터마크 등)는 콘텐츠 슬롯이
|
||||||
|
아니므로 카운트에서 제외합니다 (template_matching_taged_image의
|
||||||
|
image_slots 필터링과 동일 기준).
|
||||||
|
"""
|
||||||
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)
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
for _, (_, template_type) in enumerate(template_component_data.items()):
|
for name, template_type in template_component_data.items():
|
||||||
if template_type == target_template_type:
|
if template_type != target_template_type:
|
||||||
count += 1
|
continue
|
||||||
|
if target_template_type == "image" and (is_fixed_slot_name(name) or self.parse_slot_name_to_tag(name) is None):
|
||||||
|
continue
|
||||||
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def template_matching_taged_image(
|
def template_matching_taged_image(
|
||||||
|
|
@ -557,7 +592,14 @@ class CreatomateService:
|
||||||
assigned: dict = {} # {슬롯명: image_tag} — 자막 컨텍스트용
|
assigned: dict = {} # {슬롯명: image_tag} — 자막 컨텍스트용
|
||||||
|
|
||||||
# 이미지 슬롯과 텍스트 슬롯 분리
|
# 이미지 슬롯과 텍스트 슬롯 분리
|
||||||
image_slots = [name for name, t in template_component_data.items() if t == "image"]
|
# 고정 자산(이름이 '-fixed'로 끝나는 로고 등) 및 5토큰 명명 규칙을 따르지
|
||||||
|
# 않는 image 요소는 콘텐츠 슬롯이 아니므로 배정 대상에서 제외 — 그렇지
|
||||||
|
# 않으면 파싱 실패로 0점 처리되어 "가장 까다로운 슬롯"으로 취급되고
|
||||||
|
# 무작위 이미지로 덮어써진다.
|
||||||
|
image_slots = [
|
||||||
|
name for name, t in template_component_data.items()
|
||||||
|
if t == "image" and not is_fixed_slot_name(name) and self.parse_slot_name_to_tag(name) is not None
|
||||||
|
]
|
||||||
text_slots = [(name, t) for name, t in template_component_data.items() if t == "text"]
|
text_slots = [(name, t) for name, t in template_component_data.items() if t == "text"]
|
||||||
|
|
||||||
if not pool:
|
if not pool:
|
||||||
|
|
@ -776,20 +818,26 @@ class CreatomateService:
|
||||||
return modifications
|
return modifications
|
||||||
|
|
||||||
def modify_element(self, elements: list, modification: dict) -> list:
|
def modify_element(self, elements: list, modification: dict) -> list:
|
||||||
"""elements의 source를 modification에 따라 수정합니다."""
|
"""elements의 source를 modification에 따라 수정합니다.
|
||||||
|
|
||||||
|
modification에 없는 image/text 요소(예: '-fixed' 명명의 고정 로고·
|
||||||
|
연락처 문구처럼 배정/자막 생성 대상에서 제외된 고정 콘텐츠)는 템플릿에
|
||||||
|
이미 설정된 source/text를 그대로 보존한다 (KeyError·빈 문자열로
|
||||||
|
렌더가 깨지지 않도록).
|
||||||
|
"""
|
||||||
|
|
||||||
def recursive_modify(element: dict) -> None:
|
def recursive_modify(element: dict) -> None:
|
||||||
if "name" in element:
|
if "name" in element:
|
||||||
match element["type"]:
|
match element["type"]:
|
||||||
case "image":
|
case "image":
|
||||||
element["source"] = modification[element["name"]]
|
element["source"] = modification.get(element["name"], element.get("source"))
|
||||||
case "audio":
|
case "audio":
|
||||||
element["source"] = modification.get(element["name"], "")
|
element["source"] = modification.get(element["name"], "")
|
||||||
case "video":
|
case "video":
|
||||||
element["source"] = modification[element["name"]]
|
element["source"] = modification[element["name"]]
|
||||||
case "text":
|
case "text":
|
||||||
#element["source"] = modification[element["name"]]
|
#element["source"] = modification[element["name"]]
|
||||||
element["text"] = modification.get(element["name"], "")
|
element["text"] = modification.get(element["name"], element.get("text", ""))
|
||||||
case "composition":
|
case "composition":
|
||||||
for minor in element["elements"]:
|
for minor in element["elements"]:
|
||||||
recursive_modify(minor)
|
recursive_modify(minor)
|
||||||
|
|
@ -1127,12 +1175,38 @@ class CreatomateService:
|
||||||
case "horizontal":
|
case "horizontal":
|
||||||
return autotext_template_h_1
|
return autotext_template_h_1
|
||||||
|
|
||||||
|
def apply_language_font(self, template: dict, language: str) -> dict:
|
||||||
|
"""출력 언어에 맞춰 템플릿 내 모든 텍스트 요소의 폰트를 교체합니다.
|
||||||
|
|
||||||
|
템플릿 기본 폰트(GyeonggiTitleOTF/Pretendard)는 한글·라틴 전용이라
|
||||||
|
CJK/태국어 글리프가 없다. LANGUAGE_FONT_MAP에 있는 언어는 전체 텍스트
|
||||||
|
폰트를 해당 언어 지원 폰트(Google Fonts)로 교체하고, 매핑에 없는
|
||||||
|
언어(Korean, English 등)는 템플릿 원본 폰트를 유지한다.
|
||||||
|
"""
|
||||||
|
font_family = LANGUAGE_FONT_MAP.get(language)
|
||||||
|
if not font_family:
|
||||||
|
return template
|
||||||
|
|
||||||
|
def recursive_apply(element: dict) -> None:
|
||||||
|
if element.get("type") == "text":
|
||||||
|
element["font_family"] = font_family
|
||||||
|
for minor in element.get("elements") or []:
|
||||||
|
recursive_apply(minor)
|
||||||
|
|
||||||
|
for elem in template["source"]["elements"]:
|
||||||
|
recursive_apply(elem)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[apply_language_font] 텍스트 폰트 교체 완료 - language: {language}, font: {font_family}"
|
||||||
|
)
|
||||||
|
return template
|
||||||
|
|
||||||
def extract_text_format_from_template(self, template:dict):
|
def extract_text_format_from_template(self, template:dict):
|
||||||
keyword_list = []
|
keyword_list = []
|
||||||
subtitle_list = []
|
subtitle_list = []
|
||||||
for elem in template["source"]["elements"]:
|
for elem in template["source"]["elements"]:
|
||||||
try: #최상위 내 텍스트만 검사
|
try: #최상위 내 텍스트만 검사
|
||||||
if elem["type"] == "text":
|
if elem["type"] == "text" and not is_fixed_slot_name(elem["name"]):
|
||||||
if elem["track"] == SUBTITLE_TRACK:
|
if elem["track"] == SUBTITLE_TRACK:
|
||||||
subtitle_list.append(elem["name"])
|
subtitle_list.append(elem["name"])
|
||||||
elif elem["track"] == KEYWORD_TRACK:
|
elif elem["track"] == KEYWORD_TRACK:
|
||||||
|
|
@ -1146,20 +1220,41 @@ class CreatomateService:
|
||||||
assert(len(keyword_list)==len(subtitle_list))
|
assert(len(keyword_list)==len(subtitle_list))
|
||||||
except Exception as E:
|
except Exception as E:
|
||||||
logger.error("this template does not have same amount of keyword and subtitle.")
|
logger.error("this template does not have same amount of keyword and subtitle.")
|
||||||
pitching_list = keyword_list + subtitle_list
|
|
||||||
|
# 썸네일 텍스트(thumb-*)는 전용 트랙이 아닌 thumbnail-composition 내부에
|
||||||
|
# 중첩되어 있어 위 최상위 트랙 스캔에 잡히지 않는다. 다국어 자막 생성에
|
||||||
|
# 함께 포함되도록 재귀 파싱으로 수집한다.
|
||||||
|
component_data = self.parse_template_component_name(
|
||||||
|
template["source"]["elements"]
|
||||||
|
)
|
||||||
|
thumbnail_list = [
|
||||||
|
name
|
||||||
|
for name, elem_type in component_data.items()
|
||||||
|
if elem_type == "text" and name.startswith("thumb-")
|
||||||
|
]
|
||||||
|
|
||||||
|
pitching_list = keyword_list + subtitle_list + thumbnail_list
|
||||||
return pitching_list
|
return pitching_list
|
||||||
|
|
||||||
|
|
||||||
def make_thumbnail_modification(self, brand_name : str, region : str, category_definition : str, target_keywords : list[str]):
|
def make_thumbnail_modification(self, brand_name : str, region : str, category_definition : str, target_keywords : list[str], detail_region_info : str = ""):
|
||||||
|
|
||||||
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]]
|
||||||
|
|
||||||
|
# 지역 표기: 상세주소의 공백 기준 앞 두 마디 사용
|
||||||
|
# (예: "전북 군산시 절골길 18" → "전북 군산시").
|
||||||
|
# 상세주소가 없으면 기존 region 정규화 표기로 폴백.
|
||||||
|
if detail_region_info and detail_region_info.strip():
|
||||||
|
region_display = " ".join(detail_region_info.split()[:2])
|
||||||
|
else:
|
||||||
|
region_display = normalize_location(region)
|
||||||
|
|
||||||
mod_dict = {
|
mod_dict = {
|
||||||
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
|
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
|
||||||
"thumb-headline-brand_name-factual" : brand_name,
|
"thumb-headline-brand_name-factual" : brand_name,
|
||||||
"thumb-subheadline-local_info-factual" : normalize_location(region),
|
"thumb-subheadline-local_info-factual" : region_display,
|
||||||
"thumb-badge-category" : category_definition,
|
"thumb-badge-category" : category_definition,
|
||||||
}
|
}
|
||||||
return mod_dict
|
return mod_dict
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import copy
|
import copy
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
from typing import Literal, Any
|
from typing import Literal, Any
|
||||||
|
|
@ -12,10 +13,31 @@ from app.utils.prompts.prompts import *
|
||||||
|
|
||||||
logger = get_logger("subtitle")
|
logger = get_logger("subtitle")
|
||||||
|
|
||||||
|
# 비한국어 출력에서 번역 누락(한글 잔존)을 탐지하기 위한 패턴
|
||||||
|
_HANGUL_RE = re.compile(r"[가-힣]")
|
||||||
|
|
||||||
|
# 한글 잔존 시 GPT 재호출 최대 횟수 (최초 호출 포함)
|
||||||
|
_MAX_LANGUAGE_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
class SubtitleContentsGenerator():
|
class SubtitleContentsGenerator():
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.chatgpt_service = ChatgptService(timeout=60.0)
|
self.chatgpt_service = ChatgptService(timeout=60.0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_hangul_leftovers(output_data: SubtitlePromptOutput) -> list[str]:
|
||||||
|
"""비한국어 출력에서 한글이 남아 있는 pitching_tag 목록을 반환합니다.
|
||||||
|
|
||||||
|
프롬프트가 '한국어로 생성 후 {language}로 번역'하는 2단계 구조라서,
|
||||||
|
항목 수가 많으면 일부가 번역되지 않은 채(한국어/혼합 문장) 돌아오는
|
||||||
|
사례가 있어 코드 레벨에서 검증한다.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
result.pitching_tag
|
||||||
|
for result in output_data.pitching_results
|
||||||
|
if _HANGUL_RE.search(result.pitching_data)
|
||||||
|
]
|
||||||
|
|
||||||
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", industry: 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", industry: str = "") -> SubtitlePromptOutput:
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -41,7 +63,39 @@ class SubtitleContentsGenerator():
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[SubtitleContentsGenerator] GPT 호출 시작 - model: {dynamic_subtitle_prompt.prompt_model}"
|
f"[SubtitleContentsGenerator] GPT 호출 시작 - model: {dynamic_subtitle_prompt.prompt_model}"
|
||||||
)
|
)
|
||||||
output_data = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
|
|
||||||
|
# 비한국어 언어는 번역 누락(한글 잔존) 검증 후 필요 시 재호출.
|
||||||
|
# 전부 실패하면 잔존 건수가 가장 적은 시도를 채택한다 (영상 생성 자체는 진행).
|
||||||
|
output_data = None
|
||||||
|
best_output = None
|
||||||
|
best_leftover_count: int | None = None
|
||||||
|
for lang_attempt in range(1, _MAX_LANGUAGE_ATTEMPTS + 1):
|
||||||
|
candidate = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
|
||||||
|
|
||||||
|
if language == "Korean":
|
||||||
|
output_data = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
leftovers = self._find_hangul_leftovers(candidate)
|
||||||
|
if not leftovers:
|
||||||
|
output_data = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"[SubtitleContentsGenerator] 번역 누락(한글 잔존) {len(leftovers)}건 "
|
||||||
|
f"(attempt {lang_attempt}/{_MAX_LANGUAGE_ATTEMPTS}) - language: {language}, "
|
||||||
|
f"tags: {leftovers}"
|
||||||
|
)
|
||||||
|
if best_leftover_count is None or len(leftovers) < best_leftover_count:
|
||||||
|
best_output = candidate
|
||||||
|
best_leftover_count = len(leftovers)
|
||||||
|
|
||||||
|
if output_data is None:
|
||||||
|
logger.error(
|
||||||
|
f"[SubtitleContentsGenerator] 모든 시도에서 한글 잔존 - "
|
||||||
|
f"최소 잔존 {best_leftover_count}건 결과 채택 - language: {language}"
|
||||||
|
)
|
||||||
|
output_data = best_output
|
||||||
|
|
||||||
elapsed = (time.perf_counter() - start) * 1000
|
elapsed = (time.perf_counter() - start) * 1000
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -113,11 +113,11 @@ class SunoService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: 가사 (customMode=true일 때 가사로 사용)
|
prompt: 가사 (customMode=true일 때 가사로 사용)
|
||||||
1분 이내 길이의 노래에 적합한 가사여야 함
|
40초 이내 길이의 노래에 적합한 가사여야 함
|
||||||
genre: 음악 장르 (예: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM", "Rock", "Jazz")
|
genre: 음악 장르 (예: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM", "Rock", "Jazz")
|
||||||
None일 경우 style 파라미터를 전송하지 않음
|
None일 경우 style 파라미터를 전송하지 않음
|
||||||
callback_url: 생성 완료 시 알림 받을 URL (None일 경우 config에서 기본값 사용)
|
callback_url: 생성 완료 시 알림 받을 URL (None일 경우 config에서 기본값 사용)
|
||||||
instrumental: True이면 BGM 전용 — 더미 가사로 60초 길이를 유도하고 보컬 없이 생성
|
instrumental: True이면 BGM 전용 — 더미 가사로 40초 길이를 유도하고 보컬 없이 생성
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
task_id: 작업 추적용 ID
|
task_id: 작업 추적용 ID
|
||||||
|
|
@ -125,7 +125,7 @@ class SunoService:
|
||||||
Note:
|
Note:
|
||||||
- 스트림 URL: 30-40초 내 생성
|
- 스트림 URL: 30-40초 내 생성
|
||||||
- 다운로드 URL: 2-3분 내 생성
|
- 다운로드 URL: 2-3분 내 생성
|
||||||
- 생성되는 노래는 약 1분 이내의 길이
|
- 생성되는 노래는 약 40초 이내의 길이
|
||||||
"""
|
"""
|
||||||
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
|
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
|
||||||
|
|
||||||
|
|
@ -133,11 +133,11 @@ class SunoService:
|
||||||
|
|
||||||
if instrumental:
|
if instrumental:
|
||||||
bgm_lyrics = get_bgm_lyrics(genre)
|
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 40 seconds]\n{bgm_lyrics}"
|
||||||
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: Around 40 seconds]\n{prompt}"
|
||||||
)
|
)
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
|
|
@ -148,7 +148,7 @@ class SunoService:
|
||||||
"callBackUrl": actual_callback_url,
|
"callBackUrl": actual_callback_url,
|
||||||
}
|
}
|
||||||
if normalized_genre:
|
if normalized_genre:
|
||||||
payload["style"] = f"{normalized_genre}, around 60 seconds" if instrumental else normalized_genre
|
payload["style"] = f"{normalized_genre}, around 40 seconds" if instrumental else normalized_genre
|
||||||
|
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ 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, LANGUAGE_FONT_MAP
|
||||||
|
|
||||||
from app.comment.models import Comment
|
from app.comment.models import Comment
|
||||||
from app.database.like_cache import (
|
from app.database.like_cache import (
|
||||||
|
|
@ -192,6 +192,7 @@ async def generate_video(
|
||||||
brand_name = project.store_name
|
brand_name = project.store_name
|
||||||
region = project.region
|
region = project.region
|
||||||
industry = project.industry
|
industry = project.industry
|
||||||
|
output_language = project.language or "Korean"
|
||||||
|
|
||||||
# MarketingIntel 조회
|
# MarketingIntel 조회
|
||||||
marketing_result = await session.execute(
|
marketing_result = await session.execute(
|
||||||
|
|
@ -382,14 +383,22 @@ async def generate_video(
|
||||||
|
|
||||||
modifications.update(subtitle_modifications)
|
modifications.update(subtitle_modifications)
|
||||||
|
|
||||||
# revert thumbnail scene
|
# 썸네일 텍스트: 사전 자막 생성(LLM, 다국어) 결과에 thumb-* 슬롯이 포함되면
|
||||||
thumbnail_modifications = creatomate_service.make_thumbnail_modification(
|
# 그대로 사용한다. 과거 파이프라인 산출물(subtitle에 thumb-* 없음)은
|
||||||
|
# 기존 팩트값 조립(한국어)으로 폴백.
|
||||||
|
thumbnail_fallback = creatomate_service.make_thumbnail_modification(
|
||||||
brand_name =brand_name,
|
brand_name =brand_name,
|
||||||
region = region,
|
region = region,
|
||||||
category_definition= category_definition,
|
category_definition= category_definition,
|
||||||
target_keywords=target_keywords)
|
target_keywords=target_keywords,
|
||||||
|
detail_region_info=store_address)
|
||||||
modifications.update(thumbnail_modifications)
|
|
||||||
|
for slot_name, fallback_value in thumbnail_fallback.items():
|
||||||
|
if not modifications.get(slot_name):
|
||||||
|
modifications[slot_name] = fallback_value
|
||||||
|
logger.info(
|
||||||
|
f"[generate_video] thumbnail slot fallback(factual) 적용: {slot_name} - task_id: {task_id}"
|
||||||
|
)
|
||||||
|
|
||||||
# 6-3. elements 수정
|
# 6-3. elements 수정
|
||||||
new_elements = creatomate_service.modify_element(
|
new_elements = creatomate_service.modify_element(
|
||||||
|
|
@ -414,12 +423,8 @@ async def generate_video(
|
||||||
for i, ts in enumerate(song_timestamp_list):
|
for i, ts in enumerate(song_timestamp_list):
|
||||||
logger.debug(f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}")
|
logger.debug(f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}")
|
||||||
|
|
||||||
match lyric_language:
|
# 가사 자막 폰트: CJK/태국어는 글리프 지원 폰트로, 그 외는 Noto Sans
|
||||||
case "English" :
|
lyric_font = LANGUAGE_FONT_MAP.get(lyric_language, "Noto Sans")
|
||||||
lyric_font = "Noto Sans"
|
|
||||||
# lyric_font = "Pretendard" # 없어요
|
|
||||||
case _ :
|
|
||||||
lyric_font = "Noto Sans"
|
|
||||||
|
|
||||||
# LYRIC AUTO 결정부
|
# LYRIC AUTO 결정부
|
||||||
if (creatomate_settings.LYRIC_SUBTITLE):
|
if (creatomate_settings.LYRIC_SUBTITLE):
|
||||||
|
|
@ -439,6 +444,14 @@ async def generate_video(
|
||||||
)
|
)
|
||||||
final_template["source"]["elements"].append(caption)
|
final_template["source"]["elements"].append(caption)
|
||||||
# END - LYRIC AUTO 결정부
|
# END - LYRIC AUTO 결정부
|
||||||
|
|
||||||
|
# 언어별 폰트 교체: 템플릿 기본 폰트는 한글·라틴 전용이라 CJK/태국어는
|
||||||
|
# 지원 폰트로 전체 텍스트(자막·키워드·썸네일·가사 캡션 포함)를 교체한다.
|
||||||
|
# 가사 캡션 append 이후에 실행해야 캡션까지 커버된다.
|
||||||
|
final_template = creatomate_service.apply_language_font(
|
||||||
|
final_template, output_language
|
||||||
|
)
|
||||||
|
|
||||||
# logger.debug(
|
# logger.debug(
|
||||||
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
|
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
|
||||||
# )
|
# )
|
||||||
|
|
|
||||||
|
|
@ -857,6 +857,16 @@ async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
|
||||||
# print(stmt.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}))
|
# print(stmt.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}))
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await session.execute(stmt)).all()
|
||||||
# print("rows", rows)
|
# print("rows", rows)
|
||||||
# print(rows)
|
# img_tag가 JSON 리터럴 null로 저장된 행(태깅 실패 잔재)은 SQL IS NOT NULL 필터를
|
||||||
# print("image" , [{"image_url": row.img_url, "image_tag": row.img_tag} for row in rows])
|
# 통과하므로 파이썬 레벨에서 한 번 더 걸러낸다.
|
||||||
return [{"image_url": row.img_url, "image_tag": row.img_tag} for row in rows]
|
null_tag_urls = [row.img_url for row in rows if row.img_tag is None]
|
||||||
|
if null_tag_urls:
|
||||||
|
logger.warning(
|
||||||
|
f"[get_image_tags_by_task_id] img_tag가 null인 이미지 {len(null_tag_urls)}개 제외 "
|
||||||
|
f"- task_id: {task_id}, urls: {null_tag_urls}"
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"image_url": row.img_url, "image_tag": row.img_tag}
|
||||||
|
for row in rows
|
||||||
|
if row.img_tag is not None
|
||||||
|
]
|
||||||
Loading…
Reference in New Issue