크레아토 완료
parent
586dd5ccc9
commit
52520d770b
|
|
@ -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을 반환합니다.
|
||||||
|
|
||||||
## 경로 파라미터
|
## 경로 파라미터
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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을 반환합니다.
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 # 디렉토리가 비어있지 않으면 무시
|
||||||
|
|
|
||||||
27
config.py
27
config.py
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue