diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 65c50a7..b80e501 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -252,7 +252,8 @@ async def get_song_status( ) song = song_result.scalar_one_or_none() - if song: + if song and song.status != "completed": + # 이미 완료된 경우 백그라운드 작업 중복 실행 방지 # project_id로 Project 조회하여 store_name 가져오기 project_result = await session.execute( select(Project).where(Project.id == song.project_id) @@ -269,6 +270,8 @@ async def get_song_status( audio_url=audio_url, store_name=store_name, ) + elif song and song.status == "completed": + print(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}") print(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}") return parsed_response @@ -289,9 +292,9 @@ async def get_song_status( @router.get( "/download/{task_id}", - summary="노래 다운로드 상태 조회", + summary="노래 생성 URL 조회", description=""" -task_id를 기반으로 Song 테이블의 상태를 polling하고, +task_id를 기반으로 Song 테이블의 상태를 조회하고, completed인 경우 Project 정보와 노래 URL을 반환합니다. ## 경로 파라미터 diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index ceebef3..44f05e2 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -25,10 +25,15 @@ response = creatomate.make_creatomate_call(template_id, modifications) """ import copy +from typing import Literal import httpx -from config import apikey_settings +from config import apikey_settings, creatomate_settings + + +# Orientation 타입 정의 +OrientationType = Literal["horizontal", "vertical"] class CreatomateService: @@ -36,13 +41,40 @@ class CreatomateService: BASE_URL = "https://api.creatomate.com" - def __init__(self, api_key: str | None = None): + # 템플릿 설정 (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, + ): """ Args: api_key: Creatomate API 키 (Bearer token으로 사용) None일 경우 config에서 자동으로 가져옴 + orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical") + target_duration: 목표 영상 길이 (초) + None일 경우 orientation에 해당하는 기본값 사용 """ self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY + 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.headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", @@ -62,6 +94,14 @@ class CreatomateService: response.raise_for_status() return response.json() + async def get_one_template_data_async(self, template_id: str) -> dict: + """특정 템플릿 ID로 템플릿 정보를 비동기로 조회합니다.""" + url = f"{self.BASE_URL}/v1/templates/{template_id}" + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers, timeout=30.0) + response.raise_for_status() + return response.json() + def parse_template_component_name(self, template_source: list) -> dict: """템플릿 정보를 파싱하여 리소스 이름을 추출합니다.""" @@ -209,6 +249,18 @@ class CreatomateService: response.raise_for_status() return response.json() + async def make_creatomate_custom_call_async(self, source: dict): + """템플릿 없이 Creatomate에 비동기로 커스텀 렌더링 요청을 보냅니다. + + Note: + response에 요청 정보가 있으니 폴링 필요 + """ + url = f"{self.BASE_URL}/v2/renders" + async with httpx.AsyncClient() as client: + response = await client.post(url, json=source, headers=self.headers, timeout=60.0) + response.raise_for_status() + return response.json() + def get_render_status(self, render_id: str) -> dict: """렌더링 작업의 상태를 조회합니다. @@ -232,6 +284,30 @@ class CreatomateService: response.raise_for_status() return response.json() + async def get_render_status_async(self, render_id: str) -> dict: + """렌더링 작업의 상태를 비동기로 조회합니다. + + Args: + render_id: Creatomate 렌더 ID + + Returns: + 렌더링 상태 정보 + + Note: + 상태 값: + - planned: 예약됨 + - waiting: 대기 중 + - transcribing: 트랜스크립션 중 + - rendering: 렌더링 중 + - succeeded: 성공 + - failed: 실패 + """ + url = f"{self.BASE_URL}/v1/renders/{render_id}" + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers, timeout=30.0) + response.raise_for_status() + return response.json() + def calc_scene_duration(self, template: dict) -> float: """템플릿의 전체 장면 duration을 계산합니다.""" total_template_duration = 0.0 diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 5f5ac3d..3b11911 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -35,7 +35,7 @@ from app.video.schemas.video_schema import ( VideoListItem, VideoRenderData, ) -from app.video.worker.video_task import download_and_save_video +from app.video.worker.video_task import download_and_upload_video_to_blob from app.utils.creatomate import CreatomateService from app.utils.pagination import PaginatedResponse @@ -53,7 +53,7 @@ Creatomate API를 통해 영상 생성을 요청합니다. - **task_id**: Project/Lyric/Song의 task_id (필수) - 연관된 프로젝트, 가사, 노래를 조회하는 데 사용 ## 요청 필드 -- **template_id**: Creatomate 템플릿 ID (필수) +- **orientation**: 영상 방향 (horizontal: 가로형, vertical: 세로형, 기본값: vertical) - 선택 - **image_urls**: 영상에 사용할 이미지 URL 목록 (필수) - **lyrics**: 영상에 표시할 가사 (필수) - **music_url**: 배경 음악 URL (필수) @@ -68,8 +68,29 @@ Creatomate API를 통해 영상 생성을 요청합니다. ``` POST /video/generate/019123ab-cdef-7890-abcd-ef1234567890 { - "template_id": "abc123...", - "image_urls": ["https://...", "https://..."], + "image_urls": [ + "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_64/1715688031601oGNsV_JPEG/6.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_175/1715688031657oXc7l_JPEG/7.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_192/1715688031798MbFDj_JPEG/8.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_205/17156880318681JLwX_JPEG/9.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_142/1715688031946hhxHz_JPEG/10.jpg" + ], + "lyrics": "가사 내용...", + "music_url": "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/0694e2d8-7ae2-730c-8000-308aacaa582d/song/스테이 머뭄.mp3" +} +``` + +## 가로형 영상 생성 예시 +``` +POST /video/generate/019123ab-cdef-7890-abcd-ef1234567890 +{ + "orientation": "horizontal", + "image_urls": [...], "lyrics": "가사 내용...", "music_url": "https://..." } @@ -95,10 +116,10 @@ async def generate_video( 1. task_id로 Project, Lyric, Song 조회 2. Video 테이블에 초기 데이터 저장 (status: processing) - 3. Creatomate API 호출 + 3. Creatomate API 호출 (orientation에 따른 템플릿 자동 선택) 4. creatomate_render_id 업데이트 후 응답 반환 """ - print(f"[generate_video] START - task_id: {task_id}, template_id: {request_body.template_id}") + print(f"[generate_video] START - task_id: {task_id}, orientation: {request_body.orientation}") try: # 1. task_id로 Project 조회 project_result = await session.execute( @@ -158,23 +179,43 @@ async def generate_video( await session.flush() # ID 생성을 위해 flush print(f"[generate_video] Video saved (processing) - task_id: {task_id}") - # 5. Creatomate API 호출 + # 5. Creatomate API 호출 (POC 패턴 적용) print(f"[generate_video] Creatomate API generation started - task_id: {task_id}") - creatomate_service = CreatomateService() + # orientation에 따른 템플릿과 duration 자동 설정 + creatomate_service = CreatomateService(orientation=request_body.orientation) + print(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration}") - # 템플릿에 리소스 매핑 - modifications = creatomate_service.template_connect_resource_blackbox( - template_id=request_body.template_id, + # 5-1. 템플릿 조회 (비동기, CreatomateService에서 orientation에 맞는 template_id 사용) + template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id) + print(f"[generate_video] Template fetched - task_id: {task_id}") + + # 5-2. elements에서 리소스 매핑 생성 + modifications = creatomate_service.elements_connect_resource_blackbox( + elements=template["source"]["elements"], image_url_list=request_body.image_urls, lyric=request_body.lyrics, music_url=request_body.music_url, ) print(f"[generate_video] Modifications created - task_id: {task_id}") - # 렌더링 요청 - render_response = creatomate_service.make_creatomate_call( - template_id=request_body.template_id, - modifications=modifications, + # 5-3. elements 수정 + new_elements = creatomate_service.modify_element( + template["source"]["elements"], + modifications, + ) + template["source"]["elements"] = new_elements + print(f"[generate_video] Elements modified - task_id: {task_id}") + + # 5-4. duration 확장 (target_duration: 영상 길이) + final_template = creatomate_service.extend_template_duration( + template, + creatomate_service.target_duration, + ) + print(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}") + + # 5-5. 커스텀 렌더링 요청 (비동기) + render_response = await creatomate_service.make_creatomate_custom_call_async( + final_template["source"], ) print(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}") @@ -265,7 +306,7 @@ async def get_video_status( print(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}") try: creatomate_service = CreatomateService() - result = creatomate_service.get_render_status(creatomate_render_id) + result = await creatomate_service.get_render_status_async(creatomate_render_id) print(f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}") status = result.get("status", "unknown") @@ -293,7 +334,8 @@ async def get_video_status( ) video = video_result.scalar_one_or_none() - if video: + if video and video.status != "completed": + # 이미 완료된 경우 백그라운드 작업 중복 실행 방지 # task_id로 Project 조회하여 store_name 가져오기 project_result = await session.execute( select(Project).where(Project.id == video.project_id) @@ -302,14 +344,16 @@ async def get_video_status( store_name = project.store_name if project else "video" - # 백그라운드 태스크로 MP4 다운로드 및 DB 업데이트 + # 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제 print(f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}") background_tasks.add_task( - download_and_save_video, + download_and_upload_video_to_blob, task_id=video.task_id, video_url=video_url, store_name=store_name, ) + elif video and video.status == "completed": + print(f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}") render_data = VideoRenderData( id=result.get("id"), @@ -344,7 +388,7 @@ async def get_video_status( @router.get( "/download/{task_id}", - summary="영상 다운로드 상태 조회", + summary="영상 생성 URL 조회", description=""" task_id를 기반으로 Video 테이블의 상태를 polling하고, completed인 경우 Project 정보와 영상 URL을 반환합니다. diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index 41d5f6e..151ac50 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -5,7 +5,7 @@ Video API Schemas """ from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional from pydantic import BaseModel, Field @@ -34,18 +34,29 @@ class GenerateVideoRequest(BaseModel): model_config = { "json_schema_extra": { "example": { - "template_id": "abc123-template-id", + "orientation": "vertical", "image_urls": [ - "https://example.com/image1.jpg", - "https://example.com/image2.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_64/1715688031601oGNsV_JPEG/6.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_175/1715688031657oXc7l_JPEG/7.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_192/1715688031798MbFDj_JPEG/8.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_205/17156880318681JLwX_JPEG/9.jpg", + "https://naverbooking-phinf.pstatic.net/20240514_142/1715688031946hhxHz_JPEG/10.jpg", ], "lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요\n군산 신흥동 말랭이 마을의 마음 힐링", - "music_url": "https://example.com/song.mp3", + "music_url": "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/0694e2d8-7ae2-730c-8000-308aacaa582d/song/스테이 머뭄.mp3", } } } - template_id: str = Field(..., description="Creatomate 템플릿 ID") + orientation: Literal["horizontal", "vertical"] = Field( + default="vertical", + description="영상 방향 (horizontal: 가로형, vertical: 세로형, 기본값: vertical)", + ) image_urls: List[str] = Field(..., description="영상에 사용할 이미지 URL 목록") lyrics: str = Field(..., description="영상에 표시할 가사") music_url: str = Field(..., description="배경 음악 URL") diff --git a/app/video/worker/video_task.py b/app/video/worker/video_task.py index 7d33134..226b273 100644 --- a/app/video/worker/video_task.py +++ b/app/video/worker/video_task.py @@ -4,7 +4,6 @@ Video Background Tasks 영상 생성 관련 백그라운드 태스크를 정의합니다. """ -from datetime import date from pathlib import Path import aiofiles @@ -13,27 +12,25 @@ from sqlalchemy import select from app.database.session import AsyncSessionLocal from app.video.models import Video -from app.utils.common import generate_task_id -from config import prj_settings +from app.utils.upload_blob_as_request import AzureBlobUploader -async def download_and_save_video( +async def download_and_upload_video_to_blob( task_id: str, video_url: str, store_name: str, ) -> None: - """백그라운드에서 영상을 다운로드하고 Video 테이블을 업데이트합니다. + """백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. Args: task_id: 프로젝트 task_id video_url: 다운로드할 영상 URL store_name: 저장할 파일명에 사용할 업체명 """ - print(f"[download_and_save_video] START - task_id: {task_id}, store_name: {store_name}") + print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}") + temp_file_path: Path | None = None + try: - # 저장 경로 생성: media/{날짜}/{uuid7}/{store_name}.mp4 - today = date.today().isoformat() - unique_id = await generate_task_id() # 파일명에 사용할 수 없는 문자 제거 safe_store_name = "".join( c for c in store_name if c.isalnum() or c in (" ", "_", "-") @@ -41,27 +38,32 @@ async def download_and_save_video( safe_store_name = safe_store_name or "video" file_name = f"{safe_store_name}.mp4" - # 절대 경로 생성 - media_dir = Path("media") / today / unique_id - media_dir.mkdir(parents=True, exist_ok=True) - file_path = media_dir / file_name - print(f"[download_and_save_video] Directory created - path: {file_path}") + # 임시 저장 경로 생성 + temp_dir = Path("media") / "temp" / task_id + temp_dir.mkdir(parents=True, exist_ok=True) + temp_file_path = temp_dir / file_name + print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}") # 영상 파일 다운로드 - print(f"[download_and_save_video] Downloading video - task_id: {task_id}, url: {video_url}") + print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}") async with httpx.AsyncClient() as client: - response = await client.get(video_url, timeout=120.0) # 영상은 더 큰 timeout + response = await client.get(video_url, timeout=180.0) response.raise_for_status() - async with aiofiles.open(str(file_path), "wb") as f: + async with aiofiles.open(str(temp_file_path), "wb") as f: await f.write(response.content) - print(f"[download_and_save_video] File saved - task_id: {task_id}, path: {file_path}") + print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") - # 프론트엔드에서 접근 가능한 URL 생성 - relative_path = f"/media/{today}/{unique_id}/{file_name}" - base_url = f"http://{prj_settings.PROJECT_DOMAIN}" - file_url = f"{base_url}{relative_path}" - print(f"[download_and_save_video] URL generated - task_id: {task_id}, url: {file_url}") + # Azure Blob Storage에 업로드 + uploader = AzureBlobUploader(task_id=task_id) + upload_success = await uploader.upload_video(file_path=str(temp_file_path)) + + if not upload_success: + raise Exception("Azure Blob Storage 업로드 실패") + + # SAS 토큰이 제외된 public_url 사용 + blob_url = uploader.public_url + print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") # Video 테이블 업데이트 (새 세션 사용) async with AsyncSessionLocal() as session: @@ -76,17 +78,16 @@ async def download_and_save_video( if video: video.status = "completed" - video.result_movie_url = file_url + video.result_movie_url = blob_url await session.commit() - print(f"[download_and_save_video] SUCCESS - task_id: {task_id}, status: completed") + print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, status: completed") else: - print(f"[download_and_save_video] Video NOT FOUND in DB - task_id: {task_id}") + print(f"[download_and_upload_video_to_blob] Video NOT FOUND in DB - task_id: {task_id}") except Exception as e: - print(f"[download_and_save_video] EXCEPTION - task_id: {task_id}, error: {e}") + print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") # 실패 시 Video 테이블 업데이트 async with AsyncSessionLocal() as session: - # 여러 개 있을 경우 가장 최근 것 선택 result = await session.execute( select(Video) .where(Video.task_id == task_id) @@ -98,4 +99,144 @@ async def download_and_save_video( if video: video.status = "failed" await session.commit() - print(f"[download_and_save_video] FAILED - task_id: {task_id}, status updated to failed") + print(f"[download_and_upload_video_to_blob] FAILED - task_id: {task_id}, status updated to failed") + + finally: + # 임시 파일 삭제 + if temp_file_path and temp_file_path.exists(): + try: + temp_file_path.unlink() + print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}") + except Exception as e: + print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}") + + # 임시 디렉토리 삭제 시도 + temp_dir = Path("media") / "temp" / task_id + if temp_dir.exists(): + try: + temp_dir.rmdir() + except Exception: + pass # 디렉토리가 비어있지 않으면 무시 + + +async def download_and_upload_video_by_creatomate_render_id( + creatomate_render_id: str, + video_url: str, + store_name: str, +) -> None: + """creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. + + Args: + creatomate_render_id: Creatomate API 렌더 ID + video_url: 다운로드할 영상 URL + store_name: 저장할 파일명에 사용할 업체명 + """ + print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}") + temp_file_path: Path | None = None + task_id: str | None = None + + try: + # creatomate_render_id로 Video 조회하여 task_id 가져오기 + async with AsyncSessionLocal() as session: + result = await session.execute( + select(Video) + .where(Video.creatomate_render_id == creatomate_render_id) + .order_by(Video.created_at.desc()) + .limit(1) + ) + video = result.scalar_one_or_none() + + if not video: + print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}") + return + + task_id = video.task_id + print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}") + + # 파일명에 사용할 수 없는 문자 제거 + safe_store_name = "".join( + c for c in store_name if c.isalnum() or c in (" ", "_", "-") + ).strip() + safe_store_name = safe_store_name or "video" + file_name = f"{safe_store_name}.mp4" + + # 임시 저장 경로 생성 + temp_dir = Path("media") / "temp" / task_id + temp_dir.mkdir(parents=True, exist_ok=True) + temp_file_path = temp_dir / file_name + print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}") + + # 영상 파일 다운로드 + print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}") + async with httpx.AsyncClient() as client: + response = await client.get(video_url, timeout=180.0) + response.raise_for_status() + + async with aiofiles.open(str(temp_file_path), "wb") as f: + await f.write(response.content) + print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}") + + # Azure Blob Storage에 업로드 + uploader = AzureBlobUploader(task_id=task_id) + upload_success = await uploader.upload_video(file_path=str(temp_file_path)) + + if not upload_success: + raise Exception("Azure Blob Storage 업로드 실패") + + # SAS 토큰이 제외된 public_url 사용 + blob_url = uploader.public_url + print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}") + + # Video 테이블 업데이트 (새 세션 사용) + async with AsyncSessionLocal() as session: + result = await session.execute( + select(Video) + .where(Video.creatomate_render_id == creatomate_render_id) + .order_by(Video.created_at.desc()) + .limit(1) + ) + video = result.scalar_one_or_none() + + if video: + video.status = "completed" + video.result_movie_url = blob_url + await session.commit() + print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}, status: completed") + else: + print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND in DB - creatomate_render_id: {creatomate_render_id}") + + except Exception as e: + print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") + # 실패 시 Video 테이블 업데이트 + if task_id: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(Video) + .where(Video.creatomate_render_id == creatomate_render_id) + .order_by(Video.created_at.desc()) + .limit(1) + ) + video = result.scalar_one_or_none() + + if video: + video.status = "failed" + await session.commit() + print(f"[download_and_upload_video_by_creatomate_render_id] FAILED - creatomate_render_id: {creatomate_render_id}, status updated to failed") + + finally: + # 임시 파일 삭제 + if temp_file_path and temp_file_path.exists(): + try: + temp_file_path.unlink() + print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}") + except Exception as e: + print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}") + + # 임시 디렉토리 삭제 시도 + if task_id: + temp_dir = Path("media") / "temp" / task_id + if temp_dir.exists(): + try: + temp_dir.rmdir() + except Exception: + pass # 디렉토리가 비어있지 않으면 무시 diff --git a/config.py b/config.py index f076efe..69c200f 100644 --- a/config.py +++ b/config.py @@ -142,6 +142,32 @@ class AzureBlobSettings(BaseSettings): model_config = _base_config +class CreatomateSettings(BaseSettings): + """Creatomate 템플릿 설정""" + + # 세로형 템플릿 (기본값) + TEMPLATE_ID_VERTICAL: str = Field( + default="e8c7b43f-de4b-4ba3-b8eb-5df688569193", + description="Creatomate 세로형 템플릿 ID", + ) + TEMPLATE_DURATION_VERTICAL: float = Field( + default=90.0, + description="세로형 템플릿 기본 duration (초)", + ) + + # 가로형 템플릿 + TEMPLATE_ID_HORIZONTAL: str = Field( + default="0f092a6a-f526-4ef0-9181-d4ad4426b9e7", + description="Creatomate 가로형 템플릿 ID", + ) + TEMPLATE_DURATION_HORIZONTAL: float = Field( + default=30.0, + description="가로형 템플릿 기본 duration (초)", + ) + + model_config = _base_config + + prj_settings = ProjectSettings() apikey_settings = APIKeySettings() db_settings = DatabaseSettings() @@ -150,3 +176,4 @@ notification_settings = NotificationSettings() cors_settings = CORSSettings() crawler_settings = CrawlerSettings() azure_blob_settings = AzureBlobSettings() +creatomate_settings = CreatomateSettings()