From 7da6ab6ec08a52279cd8424dccbde49d2c5b99e2 Mon Sep 17 00:00:00 2001 From: jaehwang Date: Thu, 19 Mar 2026 01:27:45 +0000 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=82=AC=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20subtitle=20PoC=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/database/session.py | 2 + app/utils/creatomate.py | 115 +++++++++++------- app/utils/prompts/prompts.py | 10 ++ app/utils/prompts/schemas/__init__.py | 3 +- app/utils/prompts/schemas/subtitle.py | 31 +++++ .../prompts/templates/subtitle_prompt.txt | 87 +++++++++++++ app/utils/subtitles.py | 32 +++++ app/video/api/routers/v1/video.py | 96 +++++++++------ config.py | 10 +- 9 files changed, 304 insertions(+), 82 deletions(-) create mode 100644 app/utils/prompts/schemas/subtitle.py create mode 100644 app/utils/prompts/templates/subtitle_prompt.txt create mode 100644 app/utils/subtitles.py diff --git a/app/database/session.py b/app/database/session.py index e5e1b34..b7a28fe 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import DeclarativeBase from app.utils.logger import get_logger from config import db_settings +import traceback logger = get_logger("database") @@ -170,6 +171,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]: f"error: {type(e).__name__}: {e}, " f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" ) + logger.debug(traceback.format_exc()) raise e finally: total_time = time.perf_counter() - start_time diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index 3e99f20..0b86b18 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -220,6 +220,25 @@ autotext_template_h_1 = { "stroke_color": "#333333", "stroke_width": "0.2 vmin" } +DVST0001 = "75161273-0422-4771-adeb-816bd7263fb0" +DVST0002 = "c68cf750-bc40-485a-a2c5-3f9fe301e386" +DVST0003 = "e1fb5b00-1f02-4f63-99fa-7524b433ba47" +DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98" +DHST0002 = "3f194cc7-464e-4581-9db2-179d42d3e40f" +DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d" +HST_LIST = [DHST0001,DHST0002,DHST0003] +VST_LIST = [DVST0001,DVST0002,DVST0003] + +SCENE_TRACK = 1 +AUDIO_TRACK = 2 +SUBTITLE_TRACK = 3 +KEYWORD_TRACK = 4 + +def select_template(orientation:OrientationType): + if orientation == "horizontal": + return DHST0001 + elif orientation == "vertical": + return DVST0001 async def get_shared_client() -> httpx.AsyncClient: """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" @@ -264,23 +283,10 @@ class CreatomateService: BASE_URL = "https://api.creatomate.com" - # 템플릿 설정 (config에서 가져옴) - TEMPLATE_CONFIG = { - "horizontal": { - "template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL, - "duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL, - }, - "vertical": { - "template_id": creatomate_settings.TEMPLATE_ID_VERTICAL, - "duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL, - }, - } - def __init__( self, api_key: str | None = None, - orientation: OrientationType = "vertical", - target_duration: float | None = None, + orientation: OrientationType = "vertical" ): """ Args: @@ -294,14 +300,7 @@ class CreatomateService: self.orientation = orientation # orientation에 따른 템플릿 설정 가져오기 - config = self.TEMPLATE_CONFIG.get( - orientation, self.TEMPLATE_CONFIG["vertical"] - ) - self.template_id = config["template_id"] - self.target_duration = ( - target_duration if target_duration is not None else config["duration"] - ) - + self.template_id = select_template(orientation) self.headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", @@ -437,7 +436,6 @@ class CreatomateService: self, template_id: str, image_url_list: list[str], - lyric: str, music_url: str, address: str = None ) -> dict: @@ -452,9 +450,6 @@ class CreatomateService: template_component_data = self.parse_template_component_name( template_data["source"]["elements"] ) - - lyric = lyric.replace("\r", "") - lyric_splited = lyric.split("\n") modifications = {} for idx, (template_component_name, template_type) in enumerate( @@ -477,7 +472,6 @@ class CreatomateService: self, elements: list, image_url_list: list[str], - lyric: str, music_url: str, address: str = None ) -> dict: @@ -715,36 +709,54 @@ class CreatomateService: def calc_scene_duration(self, template: dict) -> float: """템플릿의 전체 장면 duration을 계산합니다.""" total_template_duration = 0.0 - + track_maximum_duration = { + SCENE_TRACK : 0, + SUBTITLE_TRACK : 0, + KEYWORD_TRACK : 0 + } for elem in template["source"]["elements"]: try: - if elem["type"] == "audio": + if elem["track"] not in track_maximum_duration: continue - total_template_duration += elem["duration"] - if "animations" not in elem: - continue - for animation in elem["animations"]: - assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 - if animation["transition"]: - total_template_duration -= animation["duration"] + if elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음 + track_maximum_duration[elem["track"]] += elem["duration"] + + if "animations" not in elem: + continue + for animation in elem["animations"]: + assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 + if animation["transition"]: + track_maximum_duration[elem["track"]] -= animation["duration"] + else: + track_maximum_duration[elem["track"]] = max(track_maximum_duration[elem["track"]], elem["time"] + elem["duration"]) + except Exception as e: logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}") + total_template_duration = max(track_maximum_duration.values()) + return total_template_duration def extend_template_duration(self, template: dict, target_duration: float) -> dict: """템플릿의 duration을 target_duration으로 확장합니다.""" - template["duration"] = target_duration + 0.5 # 늘린것보단 짧게 - target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것 + # template["duration"] = target_duration + 0.5 # 늘린것보단 짧게 + # target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것 total_template_duration = self.calc_scene_duration(template) extend_rate = target_duration / total_template_duration new_template = copy.deepcopy(template) for elem in new_template["source"]["elements"]: try: - if elem["type"] == "audio": + # if elem["type"] == "audio": + # continue + if elem["track"] == AUDIO_TRACK : # audio track은 패스 continue - elem["duration"] = elem["duration"] * extend_rate + + if "time" in elem: + elem["time"] = elem["time"] * extend_rate + if "duration" in elem: + elem["duration"] = elem["duration"] * extend_rate + if "animations" not in elem: continue for animation in elem["animations"]: @@ -785,4 +797,25 @@ class CreatomateService: return autotext_template_v_1 case "horizontal": return autotext_template_h_1 - + + def extract_text_format_from_template(self, template:dict): + keyword_list = [] + subtitle_list = [] + for elem in template["source"]["elements"]: + try: #최상위 내 텍스트만 검사 + if elem["type"] == "text": + if elem["track"] == SUBTITLE_TRACK: + subtitle_list.append(elem["name"]) + elif elem["track"] == KEYWORD_TRACK: + keyword_list.append(elem["name"]) + except Exception as e: + logger.error( + f"[extend_template_duration] Error processing element: {elem}, {e}" + ) + + try: + assert(len(keyword_list)==len(subtitle_list)) + except Exception as E: + logger.error("this template does not have same amount of keyword and subtitle.") + pitching_list = keyword_list + subtitle_list + return pitching_list \ No newline at end of file diff --git a/app/utils/prompts/prompts.py b/app/utils/prompts/prompts.py index 336318b..6f7a70e 100644 --- a/app/utils/prompts/prompts.py +++ b/app/utils/prompts/prompts.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from config import prompt_settings from app.utils.logger import get_logger from app.utils.prompts.schemas import * +from functools import lru_cache logger = get_logger("prompt") @@ -59,6 +60,15 @@ yt_upload_prompt = Prompt( prompt_model = prompt_settings.YOUTUBE_PROMPT_MODEL ) +@lru_cache() +def create_dynamic_subtitle_prompt(length : int) -> Prompt: + prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.SUBTITLE_PROMPT_FILE_NAME) + prompt_input_class = SubtitlePromptInput + prompt_output_class = SubtitlePromptOutput[length] + prompt_model = prompt_settings.SUBTITLE_PROMPT_MODEL + return Prompt(prompt_template_path, prompt_input_class, prompt_output_class, prompt_model) + + def reload_all_prompt(): marketing_prompt._reload_prompt() lyric_prompt._reload_prompt() diff --git a/app/utils/prompts/schemas/__init__.py b/app/utils/prompts/schemas/__init__.py index 8cd267f..c6cfa96 100644 --- a/app/utils/prompts/schemas/__init__.py +++ b/app/utils/prompts/schemas/__init__.py @@ -1,3 +1,4 @@ from .lyric import LyricPromptInput, LyricPromptOutput from .marketing import MarketingPromptInput, MarketingPromptOutput -from .youtube import YTUploadPromptInput, YTUploadPromptOutput \ No newline at end of file +from .youtube import YTUploadPromptInput, YTUploadPromptOutput +from .subtitle import SubtitlePromptInput, SubtitlePromptOutput \ No newline at end of file diff --git a/app/utils/prompts/schemas/subtitle.py b/app/utils/prompts/schemas/subtitle.py new file mode 100644 index 0000000..3ec94c3 --- /dev/null +++ b/app/utils/prompts/schemas/subtitle.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, create_model, Field +from typing import List, Optional +from functools import lru_cache + +# Input 정의 + +class SubtitlePromptInput(BaseModel): + 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 : +# Output 정의 +class PitchingOutput(BaseModel): + pitching_tag: str = Field(..., description="피칭 레이블") + pitching_data: str = Field(..., description = "피칭 내용물") + +class SubtitlePromptOutput(BaseModel): + pitching_results: List[PitchingOutput] = Field(..., description = "피칭 리스트") + + @classmethod + @lru_cache() + def __class_getitem__(cls, n: int): + return create_model( + cls.__name__, + pitching_results=( + List[PitchingOutput], + Field(..., min_length=n, max_length=n, description="피칭 리스트") + ), + ) \ No newline at end of file diff --git a/app/utils/prompts/templates/subtitle_prompt.txt b/app/utils/prompts/templates/subtitle_prompt.txt new file mode 100644 index 0000000..1480434 --- /dev/null +++ b/app/utils/prompts/templates/subtitle_prompt.txt @@ -0,0 +1,87 @@ +당신은 숙박 브랜드 숏폼 영상의 자막 콘텐츠를 추출하는 전문가입니다. + +입력으로 주어지는 **1) 5가지 기준의 레이어 이름 리스트**와 **2) 마케팅 인텔리전스 분석 결과(JSON)**를 바탕으로, 각 레이어 이름의 의미에 정확히 1:1 매칭되는 텍스트 콘텐츠만을 추출하세요. + +분석 결과에 없는 정보는 절대 지어내거나 추론하지 마세요. 오직 제공된 JSON 데이터 내에서만 텍스트를 구성해야 합니다. + +--- + +## 1. 레이어 네이밍 규칙 해석 및 매핑 가이드 + +입력되는 모든 레이어 이름은 예외 없이 `----` 의 5단계 구조로 되어 있습니다. +마지막의 3자리 숫자 ID(`-001`, `-002` 등)는 모든 레이어에 필수적으로 부여됩니다. + +### [1] track_role (텍스트 형태) +- `subtitle`: 씬 상황을 설명하는 간결한 문장형 텍스트 (1줄 이내) +- `keyword`: 씬을 상징하고 시선을 끄는 단답형/명사형 텍스트 (1~2단어) + +### [2] narrative_phase (영상 흐름) +- `intro`: 영상 도입부. 가장 시선을 끄는 정보를 배치. +- `core`: 핵심 매력이나 주요 편의 시설 어필. +- `highlight`: 세부적인 매력 포인트나 공간의 특별한 분위기 묘사. +- `outro`: 영상 마무리. 브랜드 명칭 복기 및 타겟/위치 정보 제공. + +### [3] content_type (데이터 매핑 대상) +- `hook_claim` 👉 `selling_points`에서 점수가 가장 높은 1순위 소구점이나 `market_positioning.core_value`를 활용하여 가장 강력한 핵심 세일즈 포인트를 어필. (가장 강력한 셀링포인트를 의미함) +- `selling_point` 👉 `selling_points`의 `description`, `korean_category` 등을 narrative 흐름에 맞춰 순차적으로 추출. +- `brand_name` 👉 JSON의 `store_name`을 추출. +- `location_info` 👉 JSON의 `detail_region_info`를 요약. +- `target_tag` 👉 `target_persona`나 `target_keywords`에서 타겟 고객군 또는 해시태그 추출. + +### [4] tone (텍스트 어조) +- `sensory`: 직관적이고 감각적인 단어 사용 +- `factual`: 과장 없이 사실 정보를 담백하게 전달 +- `empathic`: 고객의 상황에 공감하는 따뜻한 어조 +- `aspirational`: 열망을 자극하고 기대감을 주는 느낌 + +### [5] pair_id (씬 묶음 식별 번호) +- 텍스트 레이어는 `subtitle`과 `keyword`가 하나의 페어(Pair)를 이뤄 하나의 씬(Scene)에서 함께 등장합니다. +- 따라서 **동일한 씬에 속하는 `subtitle`과 `keyword` 레이어는 동일한 3자리 순번 ID(예: `-001`)**를 공유합니다. +- 영상 전반적인 씬 전개 순서에 따라 **다음 씬으로 넘어갈 때마다 ID가 순차적으로 증가**합니다. (예: 씬1은 `-001`, 씬2는 `-002`, 씬3은 `-003`...) +- **중요**: ID가 달라진다는 것은 '새로운 씬' 혹은 '다른 텍스트 쌍'을 의미하므로, **ID가 바뀌면 반드시 JSON 내의 다른 소구점이나 데이터를 추출**하여 내용이 중복되지 않도록 해야 합니다. + +--- + +## 2. 콘텐츠 추출 시 주의사항 + +1. 각 입력 레이어 이름 1개당 **오직 1개의 텍스트 콘텐츠**만 매핑하여 출력합니다. (레이어명 이름 자체를 수정하거나 새로 만들지 마세요.) +2. `content_type`이 `selling_point`로 동일하더라도, `narrative_phase`(core, highlight)나 `tone`이 달라지면 JSON 내의 2순위, 3순위 세일즈 포인트를 순차적으로 활용하여 내용 겹침을 방지하세요. +3. 같은 씬에 속하는(같은 ID 번호를 가진) keyword는 핵심 단어로, subtitle은 적절한 마케팅 문구가 되어야 하며, 자연스럽게 이어지는 문맥을 형성하도록 구성하세요. +4. keyword가 subtitle에 완전히 포함되는 단어가 되지 않도록 유의하세요. +5. 정보 태그가 같더라도 ID가 다르다면 중복되지 않는 새로운 텍스트를 도출해야 합니다. +6. 콘텐츠 추출 시 마케팅 인텔리전스의 내용을 그대로 사용하기보다는 paraphrase을 수행하세요. +7. keyword는 공백 포함 전각 8자 / 반각 16자내, subtitle은 전각 15자 / 반각 30자 내로 구성하세요. + +--- + +## 3. 출력 결과 포맷 및 예시 + +입력된 레이어 이름 순서에 맞춰, 매핑된 텍스트 콘텐츠만 작성하세요. (반드시 intro, core, highlight, outro 등 모든 씬 단계가 명확하게 매핑되어야 합니다.) + +### 입력 레이어 리스트 예시 및 출력 예시 + +| Layer Name | Text Content | +|---|---| +| subtitle-intro-hook_claim-aspirational-001 | 반려견과 눈치 없이 온전하게 쉬는 완벽한 휴식 | +| keyword-intro-brand_name-sensory-001 | 스테이펫 홍천 | +| subtitle-core-selling_point-empathic-002 | 우리만의 독립된 공간감이 주는 진정한 쉼 | +| keyword-core-selling_point-factual-002 | 프라이빗 독채 | +| subtitle-highlight-selling_point-sensory-003 | 탁 트인 야외 무드존과 포토 스팟의 감성 컷 | +| keyword-highlight-selling_point-factual-003 | 넓은 정원 | +| subtitle-outro-target_tag-empathic-004 | #강원도애견동반 #주말숏브레이크 | +| keyword-outro-location_info-factual-004 | 강원 홍천군 화촌면 | + + +# 입력 +**입력 1: 레이어 이름 리스트** +{pitching_tag_list_string} + +**입력 2: 마케팅 인텔리전스 JSON** +{marketing_intelligence} + +**입력 3: 비즈니스 정보 ** +Business Name: {customer_name} +Region Details: {detail_region_info} + + + diff --git a/app/utils/subtitles.py b/app/utils/subtitles.py new file mode 100644 index 0000000..ed489e3 --- /dev/null +++ b/app/utils/subtitles.py @@ -0,0 +1,32 @@ +import copy +import time +import json +from typing import Literal, Any + +import httpx + +from app.utils.logger import get_logger +from app.utils.chatgpt_prompt import ChatgptService +from app.utils.prompts.schemas import * +from app.utils.prompts.prompts import * + +class SubtitleContentsGenerator(): + def __init__(self): + self.chatgpt_service = ChatgptService() + + async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str) -> SubtitlePromptOutput: + 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 , + "pitching_tag_list_string" : pitching_label_string, + "customer_name" : customer_name, + "detail_region_info" : detail_region_info, + } + output_data = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data) + return output_data + + + + diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 15a101c..5f48894 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -23,10 +23,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_session from app.user.dependencies.auth import get_current_user from app.user.models import User -from app.home.models import Image, Project +from app.home.models import Image, Project, MarketingIntel from app.lyric.models import Lyric from app.song.models import Song, SongTimestamp from app.utils.creatomate import CreatomateService +from app.utils.subtitles import SubtitleContentsGenerator from app.utils.logger import get_logger from app.video.models import Video from app.video.schemas.video_schema import ( @@ -197,7 +198,15 @@ async def generate_video( detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", ) project_id = project.id + marketing_intelligence = project.marketing_intelligence store_address = project.detail_region_info + customer_name = project.store_name + marketing_intelligence = project.marketing_intelligence + + marketing_result = await session.execute( + select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) + ) + marketing_intelligence = marketing_result.scalar_one_or_none() # ===== 결과 처리: Lyric ===== lyric = lyric_result.scalar_one_or_none() @@ -287,16 +296,18 @@ async def generate_video( # 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음) # ========================================================================== stage2_start = time.perf_counter() + + subtitle_generator = SubtitleContentsGenerator() + try: logger.info( f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}" ) creatomate_service = CreatomateService( - orientation=orientation, - target_duration=song_duration, + orientation=orientation ) logger.debug( - f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})" + f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})" ) # 6-1. 템플릿 조회 (비동기) @@ -309,29 +320,42 @@ async def generate_video( modifications = creatomate_service.elements_connect_resource_blackbox( elements=template["source"]["elements"], image_url_list=image_urls, - lyric=lyrics, music_url=music_url, address=store_address ) logger.debug(f"[generate_video] Modifications created - task_id: {task_id}") + pitchings = creatomate_service.extract_text_format_from_template(template) + + 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, + ) + pitching_output_list = generated_subtitles.pitching_results + + subtitle_modifications = {pitching_output.pitching_tag : pitching_output.pitching_data for pitching_output in pitching_output_list} + + modifications.update(subtitle_modifications) # 6-3. elements 수정 new_elements = creatomate_service.modify_element( template["source"]["elements"], modifications, ) template["source"]["elements"] = new_elements + logger.debug(f"[generate_video] Elements modified - task_id: {task_id}") + # 6-4. duration 확장 final_template = creatomate_service.extend_template_duration( template, - creatomate_service.target_duration, - ) - logger.debug( - f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}" + song_duration, ) + logger.debug(f"[generate_video] Duration extended - task_id: {task_id}") + song_timestamp_result = await session.execute( select(SongTimestamp).where( SongTimestamp.suno_audio_id == song.suno_audio_id @@ -339,13 +363,10 @@ async def generate_video( ) song_timestamp_list = song_timestamp_result.scalars().all() - logger.debug( - f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}" - ) + logger.debug(f"[generate_video] song_timestamp_list count: {len(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: case "English" : @@ -355,33 +376,32 @@ async def generate_video( lyric_font = "Noto Sans" # LYRIC AUTO 결정부 - if (creatomate_settings.DEBUG_AUTO_LYRIC): - auto_text_template = creatomate_service.get_auto_text_template() - final_template["source"]["elements"].append(creatomate_service.auto_lyric(auto_text_template)) - else : - text_template = creatomate_service.get_text_template() - for idx, aligned in enumerate(song_timestamp_list): - caption = creatomate_service.lining_lyric( - text_template, - idx, - aligned.lyric_line, - aligned.start_time, - aligned.end_time, - lyric_font - ) - final_template["source"]["elements"].append(caption) + if (creatomate_settings.LYRIC_SUBTITLE): + if (creatomate_settings.DEBUG_AUTO_LYRIC): + auto_text_template = creatomate_service.get_auto_text_template() + final_template["source"]["elements"].append(creatomate_service.auto_lyric(auto_text_template)) + else : + text_template = creatomate_service.get_text_template() + for idx, aligned in enumerate(song_timestamp_list): + caption = creatomate_service.lining_lyric( + text_template, + idx, + aligned.lyric_line, + aligned.start_time, + aligned.end_time, + lyric_font + ) + final_template["source"]["elements"].append(caption) # END - LYRIC AUTO 결정부 # logger.debug( # f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}" # ) - # 6-5. 커스텀 렌더링 요청 (비동기) render_response = await creatomate_service.make_creatomate_custom_call_async( final_template["source"], ) - logger.debug( - f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}" - ) + + logger.debug(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}") # 렌더 ID 추출 if isinstance(render_response, list) and len(render_response) > 0: @@ -402,6 +422,8 @@ async def generate_video( logger.error( f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}" ) + import traceback + logger.error(traceback.format_exc()) # 외부 API 실패 시 Video 상태를 failed로 업데이트 from app.database.session import AsyncSessionLocal @@ -521,11 +543,7 @@ async def get_video_status( current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> PollingVideoResponse: - """creatomate_render_id로 영상 생성 작업의 상태를 조회합니다. - - succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 - Video 테이블의 status를 completed로, result_movie_url을 업데이트합니다. - """ + logger.info( f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}" ) diff --git a/config.py b/config.py index 12b87c1..d1a02ea 100644 --- a/config.py +++ b/config.py @@ -170,7 +170,11 @@ class CreatomateSettings(BaseSettings): ) DEBUG_AUTO_LYRIC: bool = Field( default=False, - description="Creatomate 자동 가사 생성 기능 사용 여부", + description="Creatomate 자체 자동 가사 생성 기능 사용 여부", + ) + LYRIC_SUBTITLE: bool = Field( + default=False, + description="영상 가사 표기 여부" ) model_config = _base_config @@ -186,6 +190,10 @@ class PromptSettings(BaseSettings): YOUTUBE_PROMPT_FILE_NAME : str = Field(default="yt_upload_prompt.txt") YOUTUBE_PROMPT_MODEL : str = Field(default="gpt-5-mini") + + SUBTITLE_PROMPT_FILE_NAME : str = Field(...) + SUBTITLE_PROMPT_MODEL : str = Field(...) + model_config = _base_config