Compare commits

...

3 Commits

13 changed files with 402 additions and 88 deletions

View File

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

View File

@ -300,6 +300,12 @@ class MarketingIntel(Base):
comment="마케팅 인텔리전스 결과물",
)
subtitle : Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=True,
comment="자막 정보 생성 결과물",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,

View File

@ -41,7 +41,7 @@ from app.lyric.schemas.lyric import (
LyricListItem,
LyricStatusResponse,
)
from app.lyric.worker.lyric_task import generate_lyric_background
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
from app.utils.chatgpt_prompt import ChatgptService
from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse, get_paginated
@ -351,7 +351,7 @@ async def generate_lyric(
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
step4_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
orientation = request_body.orientation
background_tasks.add_task(
generate_lyric_background,
task_id=task_id,
@ -360,6 +360,12 @@ async def generate_lyric(
lyric_id=lyric.id,
)
background_tasks.add_task(
generate_subtitle_background,
orientation = orientation,
task_id=task_id
)
step4_elapsed = (time.perf_counter() - step4_start) * 1000
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")

View File

@ -23,7 +23,7 @@ Lyric API Schemas
"""
from datetime import datetime
from typing import Optional
from typing import Optional, Literal
from pydantic import BaseModel, ConfigDict, Field
@ -42,7 +42,8 @@ class GenerateLyricRequest(BaseModel):
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean",
"m_id" : 1
"m_id" : 1,
"orientation" : "vertical"
}
"""
@ -54,7 +55,8 @@ class GenerateLyricRequest(BaseModel):
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean",
"m_id" : 1
"m_id" : 1,
"orientation" : "vertical"
}
}
)
@ -68,7 +70,11 @@ class GenerateLyricRequest(BaseModel):
language: str = Field(
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
),
orientation: Literal["horizontal", "vertical"] = Field(
default="vertical",
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
),
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")

View File

@ -7,11 +7,15 @@ Lyric Background Tasks
import traceback
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
from app.home.models import Image, Project, MarketingIntel
from app.lyric.models import Lyric
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.subtitles import SubtitleContentsGenerator
from app.utils.creatomate import CreatomateService
from app.utils.prompts.prompts import Prompt
from app.utils.logger import get_logger
@ -158,3 +162,55 @@ async def generate_lyric_background(
elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
async def generate_subtitle_background(
orientation: str,
task_id: str
) -> None:
logger.info(f"[generate_subtitle_background] task_id: {task_id}, {orientation}")
creatomate_service = CreatomateService(orientation=orientation)
template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id)
pitchings = creatomate_service.extract_text_format_from_template(template)
subtitle_generator = SubtitleContentsGenerator()
async with BackgroundSessionLocal() as session:
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
store_address = project.detail_region_info
customer_name = project.store_name
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, {store_address}")
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}
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
async with BackgroundSessionLocal() as session:
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
marketing_intelligence.subtitle = subtitle_modifications
await session.commit()
logger.info(f"[generate_subtitle_background] task_id: {task_id} DONE")
return

View File

@ -220,6 +220,27 @@ 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
else:
raise
async def get_shared_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
@ -264,23 +285,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 +302,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 +438,6 @@ class CreatomateService:
self,
template_id: str,
image_url_list: list[str],
lyric: str,
music_url: str,
address: str = None
) -> dict:
@ -452,9 +452,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 +474,6 @@ class CreatomateService:
self,
elements: list,
image_url_list: list[str],
lyric: str,
music_url: str,
address: str = None
) -> dict:
@ -715,36 +711,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 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"]:
total_template_duration -= animation["duration"]
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
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"]:
@ -786,3 +800,24 @@ class CreatomateService:
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

View File

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

View File

@ -1,3 +1,4 @@
from .lyric import LyricPromptInput, LyricPromptOutput
from .marketing import MarketingPromptInput, MarketingPromptOutput
from .youtube import YTUploadPromptInput, YTUploadPromptOutput
from .subtitle import SubtitlePromptInput, SubtitlePromptOutput

View File

@ -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="피칭 리스트")
),
)

View File

@ -0,0 +1,87 @@
당신은 숙박 브랜드 숏폼 영상의 자막 콘텐츠를 추출하는 전문가입니다.
입력으로 주어지는 **1) 5가지 기준의 레이어 이름 리스트**와 **2) 마케팅 인텔리전스 분석 결과(JSON)**를 바탕으로, 각 레이어 이름의 의미에 정확히 1:1 매칭되는 텍스트 콘텐츠만을 추출하세요.
분석 결과에 없는 정보는 절대 지어내거나 추론하지 마세요. 오직 제공된 JSON 데이터 내에서만 텍스트를 구성해야 합니다.
---
## 1. 레이어 네이밍 규칙 해석 및 매핑 가이드
입력되는 모든 레이어 이름은 예외 없이 `<track_role>-<narrative_phase>-<content_type>-<tone>-<pair_id>` 의 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}

32
app/utils/subtitles.py Normal file
View File

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

View File

@ -14,6 +14,8 @@ Video API Router
"""
import json
import asyncio
from typing import Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
@ -23,10 +25,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 (
@ -144,6 +147,34 @@ async def generate_video(
image_urls: list[str] = []
try:
subtitle_done = False
count = 0
async with AsyncSessionLocal() as session:
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
while not subtitle_done:
async with AsyncSessionLocal() as session:
logger.info(f"[generate_video] Checking subtitle- task_id: {task_id}, count : {count}")
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
subtitle_done = bool(marketing_intelligence.subtitle)
if subtitle_done:
logger.info(f"[generate_video] Check subtitle done task_id: {task_id}")
break
await asyncio.sleep(5)
if count > 12 :
raise Exception("subtitle 결과 생성 실패")
count += 1
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
async with AsyncSessionLocal() as session:
# ===== 순차 쿼리 실행: Project, Lyric, Song, Image =====
@ -198,6 +229,12 @@ async def generate_video(
)
project_id = project.id
store_address = project.detail_region_info
# customer_name = project.store_name
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 +324,16 @@ async def generate_video(
# 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음)
# ==========================================================================
stage2_start = time.perf_counter()
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 +346,32 @@ 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}")
subtitle_modifications = marketing_intelligence.subtitle
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 +379,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,6 +392,7 @@ async def generate_video(
lyric_font = "Noto Sans"
# LYRIC AUTO 결정부
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))
@ -374,14 +412,12 @@ async def generate_video(
# 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 +438,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 +559,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}"
)

View File

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