크레아토 완료

insta
bluebamus 2025-12-26 17:20:35 +09:00
parent 586dd5ccc9
commit 52520d770b
6 changed files with 362 additions and 60 deletions

View File

@ -252,7 +252,8 @@ async def get_song_status(
) )
song = song_result.scalar_one_or_none() song = song_result.scalar_one_or_none()
if song: if song and song.status != "completed":
# 이미 완료된 경우 백그라운드 작업 중복 실행 방지
# project_id로 Project 조회하여 store_name 가져오기 # project_id로 Project 조회하여 store_name 가져오기
project_result = await session.execute( project_result = await session.execute(
select(Project).where(Project.id == song.project_id) select(Project).where(Project.id == song.project_id)
@ -269,6 +270,8 @@ async def get_song_status(
audio_url=audio_url, audio_url=audio_url,
store_name=store_name, 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}") print(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}")
return parsed_response return parsed_response
@ -289,9 +292,9 @@ async def get_song_status(
@router.get( @router.get(
"/download/{task_id}", "/download/{task_id}",
summary="노래 다운로드 상태 조회", summary="노래 생성 URL 조회",
description=""" description="""
task_id를 기반으로 Song 테이블의 상태를 polling하고, task_id를 기반으로 Song 테이블의 상태를 조회하고,
completed인 경우 Project 정보와 노래 URL을 반환합니다. completed인 경우 Project 정보와 노래 URL을 반환합니다.
## 경로 파라미터 ## 경로 파라미터

View File

@ -25,10 +25,15 @@ response = creatomate.make_creatomate_call(template_id, modifications)
""" """
import copy import copy
from typing import Literal
import httpx import httpx
from config import apikey_settings from config import apikey_settings, creatomate_settings
# Orientation 타입 정의
OrientationType = Literal["horizontal", "vertical"]
class CreatomateService: class CreatomateService:
@ -36,13 +41,40 @@ class CreatomateService:
BASE_URL = "https://api.creatomate.com" 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: Args:
api_key: Creatomate API (Bearer token으로 사용) api_key: Creatomate API (Bearer token으로 사용)
None일 경우 config에서 자동으로 가져옴 None일 경우 config에서 자동으로 가져옴
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
target_duration: 목표 영상 길이 ()
None일 경우 orientation에 해당하는 기본값 사용
""" """
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY 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 = { self.headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
@ -62,6 +94,14 @@ class CreatomateService:
response.raise_for_status() response.raise_for_status()
return response.json() 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: def parse_template_component_name(self, template_source: list) -> dict:
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다.""" """템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
@ -209,6 +249,18 @@ class CreatomateService:
response.raise_for_status() response.raise_for_status()
return response.json() 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: def get_render_status(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다. """렌더링 작업의 상태를 조회합니다.
@ -232,6 +284,30 @@ class CreatomateService:
response.raise_for_status() response.raise_for_status()
return response.json() 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: def calc_scene_duration(self, template: dict) -> float:
"""템플릿의 전체 장면 duration을 계산합니다.""" """템플릿의 전체 장면 duration을 계산합니다."""
total_template_duration = 0.0 total_template_duration = 0.0

View File

@ -35,7 +35,7 @@ from app.video.schemas.video_schema import (
VideoListItem, VideoListItem,
VideoRenderData, 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.creatomate import CreatomateService
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
@ -53,7 +53,7 @@ Creatomate API를 통해 영상 생성을 요청합니다.
- **task_id**: Project/Lyric/Song의 task_id (필수) - 연관된 프로젝트, 가사, 노래를 조회하는 사용 - **task_id**: Project/Lyric/Song의 task_id (필수) - 연관된 프로젝트, 가사, 노래를 조회하는 사용
## 요청 필드 ## 요청 필드
- **template_id**: Creatomate 템플릿 ID (필수) - **orientation**: 영상 방향 (horizontal: 가로형, vertical: 세로형, 기본값: vertical) - 선택
- **image_urls**: 영상에 사용할 이미지 URL 목록 (필수) - **image_urls**: 영상에 사용할 이미지 URL 목록 (필수)
- **lyrics**: 영상에 표시할 가사 (필수) - **lyrics**: 영상에 표시할 가사 (필수)
- **music_url**: 배경 음악 URL (필수) - **music_url**: 배경 음악 URL (필수)
@ -68,8 +68,29 @@ Creatomate API를 통해 영상 생성을 요청합니다.
``` ```
POST /video/generate/019123ab-cdef-7890-abcd-ef1234567890 POST /video/generate/019123ab-cdef-7890-abcd-ef1234567890
{ {
"template_id": "abc123...", "image_urls": [
"image_urls": ["https://...", "https://..."], "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": "가사 내용...", "lyrics": "가사 내용...",
"music_url": "https://..." "music_url": "https://..."
} }
@ -95,10 +116,10 @@ async def generate_video(
1. task_id로 Project, Lyric, Song 조회 1. task_id로 Project, Lyric, Song 조회
2. Video 테이블에 초기 데이터 저장 (status: processing) 2. Video 테이블에 초기 데이터 저장 (status: processing)
3. Creatomate API 호출 3. Creatomate API 호출 (orientation에 따른 템플릿 자동 선택)
4. creatomate_render_id 업데이트 응답 반환 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: try:
# 1. task_id로 Project 조회 # 1. task_id로 Project 조회
project_result = await session.execute( project_result = await session.execute(
@ -158,23 +179,43 @@ async def generate_video(
await session.flush() # ID 생성을 위해 flush await session.flush() # ID 생성을 위해 flush
print(f"[generate_video] Video saved (processing) - task_id: {task_id}") 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}") 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}")
# 템플릿에 리소스 매핑 # 5-1. 템플릿 조회 (비동기, CreatomateService에서 orientation에 맞는 template_id 사용)
modifications = creatomate_service.template_connect_resource_blackbox( template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id)
template_id=request_body.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, image_url_list=request_body.image_urls,
lyric=request_body.lyrics, lyric=request_body.lyrics,
music_url=request_body.music_url, music_url=request_body.music_url,
) )
print(f"[generate_video] Modifications created - task_id: {task_id}") print(f"[generate_video] Modifications created - task_id: {task_id}")
# 렌더링 요청 # 5-3. elements 수정
render_response = creatomate_service.make_creatomate_call( new_elements = creatomate_service.modify_element(
template_id=request_body.template_id, template["source"]["elements"],
modifications=modifications, 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}") 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}") print(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}")
try: try:
creatomate_service = CreatomateService() 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')}") print(f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}")
status = result.get("status", "unknown") status = result.get("status", "unknown")
@ -293,7 +334,8 @@ async def get_video_status(
) )
video = video_result.scalar_one_or_none() video = video_result.scalar_one_or_none()
if video: if video and video.status != "completed":
# 이미 완료된 경우 백그라운드 작업 중복 실행 방지
# task_id로 Project 조회하여 store_name 가져오기 # task_id로 Project 조회하여 store_name 가져오기
project_result = await session.execute( project_result = await session.execute(
select(Project).where(Project.id == video.project_id) 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" 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}") 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( background_tasks.add_task(
download_and_save_video, download_and_upload_video_to_blob,
task_id=video.task_id, task_id=video.task_id,
video_url=video_url, video_url=video_url,
store_name=store_name, 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( render_data = VideoRenderData(
id=result.get("id"), id=result.get("id"),
@ -344,7 +388,7 @@ async def get_video_status(
@router.get( @router.get(
"/download/{task_id}", "/download/{task_id}",
summary="영상 다운로드 상태 조회", summary="영상 생성 URL 조회",
description=""" description="""
task_id를 기반으로 Video 테이블의 상태를 polling하고, task_id를 기반으로 Video 테이블의 상태를 polling하고,
completed인 경우 Project 정보와 영상 URL을 반환합니다. completed인 경우 Project 정보와 영상 URL을 반환합니다.

View File

@ -5,7 +5,7 @@ Video API Schemas
""" """
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -34,18 +34,29 @@ class GenerateVideoRequest(BaseModel):
model_config = { model_config = {
"json_schema_extra": { "json_schema_extra": {
"example": { "example": {
"template_id": "abc123-template-id", "orientation": "vertical",
"image_urls": [ "image_urls": [
"https://example.com/image1.jpg", "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg",
"https://example.com/image2.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군산 신흥동 말랭이 마을의 마음 힐링", "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 목록") image_urls: List[str] = Field(..., description="영상에 사용할 이미지 URL 목록")
lyrics: str = Field(..., description="영상에 표시할 가사") lyrics: str = Field(..., description="영상에 표시할 가사")
music_url: str = Field(..., description="배경 음악 URL") music_url: str = Field(..., description="배경 음악 URL")

View File

@ -4,7 +4,6 @@ Video Background Tasks
영상 생성 관련 백그라운드 태스크를 정의합니다. 영상 생성 관련 백그라운드 태스크를 정의합니다.
""" """
from datetime import date
from pathlib import Path from pathlib import Path
import aiofiles import aiofiles
@ -13,27 +12,25 @@ from sqlalchemy import select
from app.database.session import AsyncSessionLocal from app.database.session import AsyncSessionLocal
from app.video.models import Video from app.video.models import Video
from app.utils.common import generate_task_id from app.utils.upload_blob_as_request import AzureBlobUploader
from config import prj_settings
async def download_and_save_video( async def download_and_upload_video_to_blob(
task_id: str, task_id: str,
video_url: str, video_url: str,
store_name: str, store_name: str,
) -> None: ) -> None:
"""백그라운드에서 영상을 다운로드하고 Video 테이블을 업데이트합니다. """백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
Args: Args:
task_id: 프로젝트 task_id task_id: 프로젝트 task_id
video_url: 다운로드할 영상 URL video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명 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: try:
# 저장 경로 생성: media/{날짜}/{uuid7}/{store_name}.mp4
today = date.today().isoformat()
unique_id = await generate_task_id()
# 파일명에 사용할 수 없는 문자 제거 # 파일명에 사용할 수 없는 문자 제거
safe_store_name = "".join( safe_store_name = "".join(
c for c in store_name if c.isalnum() or c in (" ", "_", "-") 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" safe_store_name = safe_store_name or "video"
file_name = f"{safe_store_name}.mp4" file_name = f"{safe_store_name}.mp4"
# 절대 경로 생성 # 임시 저장 경로 생성
media_dir = Path("media") / today / unique_id temp_dir = Path("media") / "temp" / task_id
media_dir.mkdir(parents=True, exist_ok=True) temp_dir.mkdir(parents=True, exist_ok=True)
file_path = media_dir / file_name temp_file_path = temp_dir / file_name
print(f"[download_and_save_video] Directory created - path: {file_path}") 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: 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() 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) 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 생성 # Azure Blob Storage에 업로드
relative_path = f"/media/{today}/{unique_id}/{file_name}" uploader = AzureBlobUploader(task_id=task_id)
base_url = f"http://{prj_settings.PROJECT_DOMAIN}" upload_success = await uploader.upload_video(file_path=str(temp_file_path))
file_url = f"{base_url}{relative_path}"
print(f"[download_and_save_video] URL generated - task_id: {task_id}, url: {file_url}") 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 테이블 업데이트 (새 세션 사용) # Video 테이블 업데이트 (새 세션 사용)
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
@ -76,17 +78,16 @@ async def download_and_save_video(
if video: if video:
video.status = "completed" video.status = "completed"
video.result_movie_url = file_url video.result_movie_url = blob_url
await session.commit() 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: 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: 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 테이블 업데이트 # 실패 시 Video 테이블 업데이트
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# 여러 개 있을 경우 가장 최근 것 선택
result = await session.execute( result = await session.execute(
select(Video) select(Video)
.where(Video.task_id == task_id) .where(Video.task_id == task_id)
@ -98,4 +99,144 @@ async def download_and_save_video(
if video: if video:
video.status = "failed" video.status = "failed"
await session.commit() 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 # 디렉토리가 비어있지 않으면 무시

View File

@ -142,6 +142,32 @@ class AzureBlobSettings(BaseSettings):
model_config = _base_config 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() prj_settings = ProjectSettings()
apikey_settings = APIKeySettings() apikey_settings = APIKeySettings()
db_settings = DatabaseSettings() db_settings = DatabaseSettings()
@ -150,3 +176,4 @@ notification_settings = NotificationSettings()
cors_settings = CORSSettings() cors_settings = CORSSettings()
crawler_settings = CrawlerSettings() crawler_settings = CrawlerSettings()
azure_blob_settings = AzureBlobSettings() azure_blob_settings = AzureBlobSettings()
creatomate_settings = CreatomateSettings()