Compare commits
No commits in common. "b7edba8c80f710a164df66e5751d71107b30c869" and "3432d5189bf4caede476e03923c1364d6620d204" have entirely different histories.
b7edba8c80
...
3432d5189b
|
|
@ -27,6 +27,3 @@ build/
|
||||||
*.mp3
|
*.mp3
|
||||||
*.mp4
|
*.mp4
|
||||||
media/
|
media/
|
||||||
|
|
||||||
|
|
||||||
*.ipynb_checkpoint*
|
|
||||||
|
|
@ -33,18 +33,10 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
# Shutdown - 애플리케이션 종료 시
|
# Shutdown - 애플리케이션 종료 시
|
||||||
print("Shutting down...")
|
print("Shutting down...")
|
||||||
|
from app.database.session import engine
|
||||||
|
|
||||||
# 공유 HTTP 클라이언트 종료
|
await engine.dispose()
|
||||||
from app.utils.creatomate import close_shared_client
|
print("Database engine disposed")
|
||||||
from app.utils.upload_blob_as_request import close_shared_blob_client
|
|
||||||
|
|
||||||
await close_shared_client()
|
|
||||||
await close_shared_blob_client()
|
|
||||||
|
|
||||||
# 데이터베이스 엔진 종료
|
|
||||||
from app.database.session import dispose_engine
|
|
||||||
|
|
||||||
await dispose_engine()
|
|
||||||
|
|
||||||
|
|
||||||
# FastAPI 앱 생성 (lifespan 적용)
|
# FastAPI 앱 생성 (lifespan 적용)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import time
|
from contextlib import asynccontextmanager
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from config import db_settings
|
from config import db_settings
|
||||||
|
|
||||||
|
|
@ -11,25 +12,24 @@ class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# 데이터베이스 엔진 생성
|
||||||
# 메인 엔진 (FastAPI 요청용)
|
|
||||||
# =============================================================================
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
url=db_settings.MYSQL_URL,
|
url=db_settings.MYSQL_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_size=20, # 기본 풀 크기: 20
|
pool_size=10,
|
||||||
max_overflow=20, # 추가 연결: 20 (총 최대 40)
|
max_overflow=10,
|
||||||
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
|
pool_timeout=5,
|
||||||
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
|
pool_recycle=3600,
|
||||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
pool_pre_ping=True,
|
||||||
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
|
pool_reset_on_return="rollback",
|
||||||
connect_args={
|
connect_args={
|
||||||
"connect_timeout": 10, # DB 연결 타임아웃
|
"connect_timeout": 3,
|
||||||
"charset": "utf8mb4",
|
"charset": "utf8mb4",
|
||||||
|
# "allow_public_key_retrieval": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 메인 세션 팩토리 (FastAPI DI용)
|
# Async sessionmaker 생성
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
bind=engine,
|
bind=engine,
|
||||||
class_=AsyncSession,
|
class_=AsyncSession,
|
||||||
|
|
@ -38,33 +38,6 @@ AsyncSessionLocal = async_sessionmaker(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 백그라운드 태스크 전용 엔진 (메인 풀과 분리)
|
|
||||||
# =============================================================================
|
|
||||||
background_engine = create_async_engine(
|
|
||||||
url=db_settings.MYSQL_URL,
|
|
||||||
echo=False,
|
|
||||||
pool_size=10, # 백그라운드용 풀 크기: 10
|
|
||||||
max_overflow=10, # 추가 연결: 10 (총 최대 20)
|
|
||||||
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
|
|
||||||
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
|
||||||
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
|
|
||||||
pool_reset_on_return="rollback",
|
|
||||||
connect_args={
|
|
||||||
"connect_timeout": 10,
|
|
||||||
"charset": "utf8mb4",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# 백그라운드 세션 팩토리
|
|
||||||
BackgroundSessionLocal = async_sessionmaker(
|
|
||||||
bind=background_engine,
|
|
||||||
class_=AsyncSession,
|
|
||||||
expire_on_commit=False,
|
|
||||||
autoflush=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_db_tables():
|
async def create_db_tables():
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|
@ -83,79 +56,72 @@ async def create_db_tables():
|
||||||
|
|
||||||
# FastAPI 의존성용 세션 제너레이터
|
# FastAPI 의존성용 세션 제너레이터
|
||||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
start_time = time.perf_counter()
|
|
||||||
pool = engine.pool
|
|
||||||
|
|
||||||
# 커넥션 풀 상태 로깅 (디버깅용)
|
|
||||||
print(
|
|
||||||
f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
|
||||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
|
||||||
f"overflow: {pool.overflow()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
acquire_time = time.perf_counter()
|
|
||||||
print(
|
|
||||||
f"[get_session] Session acquired in "
|
|
||||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
|
# print("Session commited")
|
||||||
|
# await session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
print(
|
print(f"Session rollback due to: {e}")
|
||||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
raise e
|
raise e
|
||||||
finally:
|
# async with 종료 시 session.close()가 자동 호출됨
|
||||||
total_time = time.perf_counter() - start_time
|
|
||||||
print(
|
|
||||||
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
|
|
||||||
f"pool_out: {pool.checkedout()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 백그라운드 태스크용 세션 제너레이터
|
|
||||||
async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
start_time = time.perf_counter()
|
|
||||||
pool = background_engine.pool
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
|
|
||||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
|
||||||
f"overflow: {pool.overflow()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
acquire_time = time.perf_counter()
|
|
||||||
print(
|
|
||||||
f"[get_background_session] Session acquired in "
|
|
||||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
print(
|
|
||||||
f"[get_background_session] ROLLBACK - "
|
|
||||||
f"error: {type(e).__name__}: {e}, "
|
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
raise e
|
|
||||||
finally:
|
|
||||||
total_time = time.perf_counter() - start_time
|
|
||||||
print(
|
|
||||||
f"[get_background_session] RELEASE - "
|
|
||||||
f"duration: {total_time*1000:.1f}ms, "
|
|
||||||
f"pool_out: {pool.checkedout()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 앱 종료 시 엔진 리소스 정리 함수
|
# 앱 종료 시 엔진 리소스 정리 함수
|
||||||
async def dispose_engine() -> None:
|
async def dispose_engine() -> None:
|
||||||
print("[dispose_engine] Disposing database engines...")
|
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
print("[dispose_engine] Main engine disposed")
|
print("Database engine disposed")
|
||||||
await background_engine.dispose()
|
|
||||||
print("[dispose_engine] Background engine disposed - ALL DONE")
|
|
||||||
|
# =============================================================================
|
||||||
|
# 백그라운드 태스크용 세션 (별도 이벤트 루프에서 사용)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""백그라운드 태스크용 세션 컨텍스트 매니저
|
||||||
|
|
||||||
|
asyncio.run()으로 새 이벤트 루프를 생성하는 백그라운드 태스크에서 사용합니다.
|
||||||
|
NullPool을 사용하여 연결 풀링을 비활성화하고, 이벤트 루프 충돌을 방지합니다.
|
||||||
|
|
||||||
|
get_session()과의 차이점:
|
||||||
|
- get_session(): FastAPI DI용, 메인 이벤트 루프의 연결 풀 사용
|
||||||
|
- get_worker_session(): 백그라운드 태스크용, NullPool로 매번 새 연결 생성
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async with get_worker_session() as session:
|
||||||
|
result = await session.execute(select(Model))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- 매 호출마다 엔진을 생성하고 dispose하므로 오버헤드가 있음
|
||||||
|
- 빈번한 호출이 필요한 경우 방법 1(모듈 레벨 엔진)을 고려
|
||||||
|
"""
|
||||||
|
worker_engine = create_async_engine(
|
||||||
|
url=db_settings.MYSQL_URL,
|
||||||
|
poolclass=NullPool,
|
||||||
|
connect_args={
|
||||||
|
"connect_timeout": 3,
|
||||||
|
"charset": "utf8mb4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session_factory = async_sessionmaker(
|
||||||
|
bind=worker_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
print(f"Worker session rollback due to: {e}")
|
||||||
|
raise e
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
await worker_engine.dispose()
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,15 @@
|
||||||
import json
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
from urllib.parse import unquote, urlparse
|
|
||||||
|
|
||||||
import aiofiles
|
from fastapi import APIRouter
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database.session import get_session, AsyncSessionLocal
|
from app.home.schemas.home import (
|
||||||
from app.home.models import Image
|
|
||||||
from app.home.schemas.home_schema import (
|
|
||||||
CrawlingRequest,
|
CrawlingRequest,
|
||||||
CrawlingResponse,
|
CrawlingResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
ImageUploadResponse,
|
|
||||||
ImageUploadResultItem,
|
|
||||||
ImageUrlItem,
|
|
||||||
MarketingAnalysis,
|
MarketingAnalysis,
|
||||||
ProcessedInfo,
|
ProcessedInfo,
|
||||||
)
|
)
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
|
||||||
from app.utils.chatgpt_prompt import ChatgptService
|
from app.utils.chatgpt_prompt import ChatgptService
|
||||||
from app.utils.common import generate_task_id
|
|
||||||
from app.utils.nvMapScraper import NvMapScraper
|
from app.utils.nvMapScraper import NvMapScraper
|
||||||
|
|
||||||
MEDIA_ROOT = Path("media")
|
MEDIA_ROOT = Path("media")
|
||||||
|
|
@ -136,6 +123,8 @@ async def crawling(request_body: CrawlingRequest):
|
||||||
def _extract_image_name(url: str, index: int) -> str:
|
def _extract_image_name(url: str, index: int) -> str:
|
||||||
"""URL에서 이미지 이름 추출 또는 기본 이름 생성"""
|
"""URL에서 이미지 이름 추출 또는 기본 이름 생성"""
|
||||||
try:
|
try:
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
path = urlparse(url).path
|
path = urlparse(url).path
|
||||||
filename = path.split("/")[-1] if path else ""
|
filename = path.split("/")[-1] if path else ""
|
||||||
if filename:
|
if filename:
|
||||||
|
|
@ -145,545 +134,259 @@ def _extract_image_name(url: str, index: int) -> str:
|
||||||
return f"image_{index + 1:03d}"
|
return f"image_{index + 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"}
|
# @router.post(
|
||||||
|
# "/generate",
|
||||||
|
# summary="기본 영상 생성 요청",
|
||||||
def _is_valid_image_extension(filename: str | None) -> bool:
|
# description="""
|
||||||
"""파일명의 확장자가 유효한 이미지 확장자인지 확인"""
|
# 고객 정보만 받아 영상 생성 작업을 시작합니다. (이미지 없음)
|
||||||
if not filename:
|
|
||||||
return False
|
# ## 요청 필드
|
||||||
ext = Path(filename).suffix.lower()
|
# - **customer_name**: 고객명/가게명 (필수)
|
||||||
return ext in ALLOWED_IMAGE_EXTENSIONS
|
# - **region**: 지역명 (필수)
|
||||||
|
# - **detail_region_info**: 상세 지역 정보 (선택)
|
||||||
|
# - **attribute**: 음악 속성 정보 (genre, vocal, tempo, mood)
|
||||||
def _get_file_extension(filename: str) -> str:
|
|
||||||
"""파일명에서 확장자 추출 (소문자)"""
|
# ## 반환 정보
|
||||||
return Path(filename).suffix.lower()
|
# - **task_id**: 작업 고유 식별자 (UUID7)
|
||||||
|
# - **status**: 작업 상태
|
||||||
|
# - **message**: 응답 메시지
|
||||||
async def _save_upload_file(file: UploadFile, save_path: Path) -> None:
|
# """,
|
||||||
"""업로드 파일을 지정된 경로에 저장"""
|
# response_model=GenerateResponse,
|
||||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
# response_description="생성 작업 시작 결과",
|
||||||
async with aiofiles.open(save_path, "wb") as f:
|
# tags=["generate"],
|
||||||
content = await file.read()
|
# )
|
||||||
await f.write(content)
|
# async def generate(
|
||||||
|
# request_body: GenerateRequest,
|
||||||
|
# background_tasks: BackgroundTasks,
|
||||||
IMAGES_JSON_EXAMPLE = """[
|
# session: AsyncSession = Depends(get_session),
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"},
|
# ):
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"},
|
# """기본 영상 생성 요청 처리 (이미지 없음)"""
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"},
|
# # UUID7 생성 및 중복 검사
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"},
|
# while True:
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
|
# task_id = str(uuid7())
|
||||||
]"""
|
# existing = await session.execute(
|
||||||
|
# select(Project).where(Project.task_id == task_id)
|
||||||
|
# )
|
||||||
@router.post(
|
# if existing.scalar_one_or_none() is None:
|
||||||
"/image/upload/server",
|
# break
|
||||||
include_in_schema=False,
|
|
||||||
summary="이미지 업로드 (로컬 서버)",
|
# # Project 생성 (이미지 없음)
|
||||||
description="""
|
# project = Project(
|
||||||
이미지를 로컬 서버(media 폴더)에 업로드하고 새로운 task_id를 생성합니다.
|
# store_name=request_body.customer_name,
|
||||||
|
# region=request_body.region,
|
||||||
## 요청 방식
|
# task_id=task_id,
|
||||||
multipart/form-data 형식으로 전송합니다.
|
# detail_region_info=json.dumps(
|
||||||
|
# {
|
||||||
## 요청 필드
|
# "detail": request_body.detail_region_info,
|
||||||
- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택)
|
# "attribute": request_body.attribute.model_dump(),
|
||||||
- **files**: 이미지 바이너리 파일 목록 (선택)
|
# },
|
||||||
|
# ensure_ascii=False,
|
||||||
**주의**: images_json 또는 files 중 최소 하나는 반드시 전달해야 합니다.
|
# ),
|
||||||
|
# )
|
||||||
## 지원 이미지 확장자
|
# session.add(project)
|
||||||
jpg, jpeg, png, webp, heic, heif
|
# await session.commit()
|
||||||
|
# await session.refresh(project)
|
||||||
## images_json 예시
|
|
||||||
```json
|
# background_tasks.add_task(task_process, request_body, task_id, project.id)
|
||||||
[
|
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"},
|
# return {
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"},
|
# "task_id": task_id,
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"},
|
# "status": "processing",
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"},
|
# "message": "생성 작업이 시작되었습니다.",
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
|
# }
|
||||||
]
|
|
||||||
```
|
|
||||||
|
# @router.post(
|
||||||
## 바이너리 파일 업로드 테스트 방법
|
# "/generate/urls",
|
||||||
|
# summary="URL 기반 영상 생성 요청",
|
||||||
### 1. Swagger UI에서 테스트
|
# description="""
|
||||||
1. 이 엔드포인트의 "Try it out" 버튼 클릭
|
# 고객 정보와 이미지 URL을 받아 영상 생성 작업을 시작합니다.
|
||||||
2. task_id 입력 (예: test-task-001)
|
|
||||||
3. files 항목에서 "Add item" 클릭하여 로컬 이미지 파일 선택
|
# ## 요청 필드
|
||||||
4. (선택) images_json에 URL 목록 JSON 입력
|
# - **customer_name**: 고객명/가게명 (필수)
|
||||||
5. "Execute" 버튼 클릭
|
# - **region**: 지역명 (필수)
|
||||||
|
# - **detail_region_info**: 상세 지역 정보 (선택)
|
||||||
### 2. cURL로 테스트
|
# - **attribute**: 음악 속성 정보 (genre, vocal, tempo, mood)
|
||||||
```bash
|
# - **images**: 이미지 URL 목록 (필수)
|
||||||
# 바이너리 파일만 업로드
|
|
||||||
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
|
# ## 반환 정보
|
||||||
-F "files=@/path/to/image1.jpg" \\
|
# - **task_id**: 작업 고유 식별자 (UUID7)
|
||||||
-F "files=@/path/to/image2.png"
|
# - **status**: 작업 상태
|
||||||
|
# - **message**: 응답 메시지
|
||||||
# URL + 바이너리 파일 동시 업로드
|
# """,
|
||||||
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
|
# response_model=GenerateResponse,
|
||||||
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
|
# response_description="생성 작업 시작 결과",
|
||||||
-F "files=@/path/to/local_image.jpg"
|
# tags=["generate"],
|
||||||
```
|
# )
|
||||||
|
# async def generate_urls(
|
||||||
### 3. Python requests로 테스트
|
# request_body: GenerateUrlsRequest,
|
||||||
```python
|
# session: AsyncSession = Depends(get_session),
|
||||||
import requests
|
# ):
|
||||||
|
# """URL 기반 영상 생성 요청 처리"""
|
||||||
url = "http://localhost:8000/image/upload/server/test-task-001"
|
# # UUID7 생성 및 중복 검사
|
||||||
files = [
|
# while True:
|
||||||
("files", ("image1.jpg", open("image1.jpg", "rb"), "image/jpeg")),
|
# task_id = str(uuid7())
|
||||||
("files", ("image2.png", open("image2.png", "rb"), "image/png")),
|
# existing = await session.execute(
|
||||||
]
|
# select(Project).where(Project.task_id == task_id)
|
||||||
data = {
|
# )
|
||||||
"images_json": '[{"url": "https://example.com/image.jpg"}]'
|
# if existing.scalar_one_or_none() is None:
|
||||||
}
|
# break
|
||||||
response = requests.post(url, files=files, data=data)
|
|
||||||
print(response.json())
|
# # Project 생성 (이미지 정보 제외)
|
||||||
```
|
# project = Project(
|
||||||
|
# store_name=request_body.customer_name,
|
||||||
## 반환 정보
|
# region=request_body.region,
|
||||||
- **task_id**: 작업 고유 식별자
|
# task_id=task_id,
|
||||||
- **total_count**: 총 업로드된 이미지 개수
|
# detail_region_info=json.dumps(
|
||||||
- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장)
|
# {
|
||||||
- **file_count**: 파일로 업로드된 이미지 개수 (media 폴더에 저장)
|
# "detail": request_body.detail_region_info,
|
||||||
- **saved_count**: Image 테이블에 저장된 row 수
|
# "attribute": request_body.attribute.model_dump(),
|
||||||
- **images**: 업로드된 이미지 목록
|
# },
|
||||||
- **source**: "url" (외부 URL) 또는 "file" (로컬 서버 저장)
|
# ensure_ascii=False,
|
||||||
|
# ),
|
||||||
## 저장 경로
|
# )
|
||||||
- 바이너리 파일: /media/image/{날짜}/{uuid7}/{파일명}
|
# session.add(project)
|
||||||
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
|
|
||||||
|
# # Image 레코드 생성 (독립 테이블, task_id로 연결)
|
||||||
## 반환 정보
|
# for idx, img_item in enumerate(request_body.images):
|
||||||
- **task_id**: 새로 생성된 작업 고유 식별자
|
# # name이 있으면 사용, 없으면 URL에서 추출
|
||||||
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
|
# img_name = img_item.name or _extract_image_name(img_item.url, idx)
|
||||||
""",
|
# image = Image(
|
||||||
response_model=ImageUploadResponse,
|
# task_id=task_id,
|
||||||
responses={
|
# img_name=img_name,
|
||||||
200: {"description": "이미지 업로드 성공"},
|
# img_url=img_item.url,
|
||||||
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
# img_order=idx,
|
||||||
},
|
# )
|
||||||
tags=["image"],
|
# session.add(image)
|
||||||
)
|
|
||||||
async def upload_images(
|
# await session.commit()
|
||||||
images_json: Optional[str] = Form(
|
|
||||||
default=None,
|
# return {
|
||||||
description="외부 이미지 URL 목록 (JSON 문자열)",
|
# "task_id": task_id,
|
||||||
example=IMAGES_JSON_EXAMPLE,
|
# "status": "processing",
|
||||||
),
|
# "message": "생성 작업이 시작되었습니다.",
|
||||||
files: Optional[list[UploadFile]] = File(
|
# }
|
||||||
default=None, description="이미지 바이너리 파일 목록"
|
|
||||||
),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
# async def _save_upload_file(file: UploadFile, save_path: Path) -> None:
|
||||||
) -> ImageUploadResponse:
|
# """업로드 파일을 지정된 경로에 저장"""
|
||||||
"""이미지 업로드 (URL + 바이너리 파일)"""
|
# save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# task_id 생성
|
# async with aiofiles.open(save_path, "wb") as f:
|
||||||
task_id = await generate_task_id()
|
# content = await file.read()
|
||||||
|
# await f.write(content)
|
||||||
# 1. 진입 검증: images_json 또는 files 중 하나는 반드시 있어야 함
|
|
||||||
has_images_json = images_json is not None and images_json.strip() != ""
|
|
||||||
has_files = files is not None and len(files) > 0
|
# def _get_file_extension(filename: str | None) -> str:
|
||||||
|
# """파일명에서 확장자 추출"""
|
||||||
if not has_images_json and not has_files:
|
# if not filename:
|
||||||
raise HTTPException(
|
# return ".jpg"
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
# ext = Path(filename).suffix.lower()
|
||||||
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
|
# return ext if ext else ".jpg"
|
||||||
)
|
|
||||||
|
|
||||||
# 2. images_json 파싱 (있는 경우만)
|
# @router.post(
|
||||||
url_images: list[ImageUrlItem] = []
|
# "/generate/upload",
|
||||||
if has_images_json:
|
# summary="파일 업로드 기반 영상 생성 요청",
|
||||||
try:
|
# description="""
|
||||||
parsed = json.loads(images_json)
|
# 고객 정보와 이미지 파일을 받아 영상 생성 작업을 시작합니다.
|
||||||
if isinstance(parsed, list):
|
|
||||||
url_images = [ImageUrlItem(**item) for item in parsed if item]
|
# ## 요청 필드 (multipart/form-data)
|
||||||
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
# - **customer_name**: 고객명/가게명 (필수)
|
||||||
raise HTTPException(
|
# - **region**: 지역명 (필수)
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
# - **detail_region_info**: 상세 지역 정보 (선택)
|
||||||
detail=f"images_json 파싱 오류: {str(e)}",
|
# - **attribute**: 음악 속성 정보 JSON 문자열 (필수)
|
||||||
)
|
# - **images**: 이미지 파일 목록 (필수, 복수 파일)
|
||||||
|
|
||||||
# 3. 유효한 파일만 필터링 (빈 파일, 유효한 이미지 확장자가 아닌 경우 제외)
|
# ## 반환 정보
|
||||||
valid_files: list[UploadFile] = []
|
# - **task_id**: 작업 고유 식별자 (UUID7)
|
||||||
skipped_files: list[str] = []
|
# - **status**: 작업 상태
|
||||||
if has_files and files:
|
# - **message**: 응답 메시지
|
||||||
for f in files:
|
# - **uploaded_count**: 업로드된 이미지 개수
|
||||||
is_valid_ext = _is_valid_image_extension(f.filename)
|
# """,
|
||||||
is_not_empty = (
|
# response_model=GenerateUploadResponse,
|
||||||
f.size is None or f.size > 0
|
# response_description="생성 작업 시작 결과",
|
||||||
) # size가 None이면 아직 읽지 않은 것
|
# tags=["generate"],
|
||||||
is_real_file = (
|
# )
|
||||||
f.filename and f.filename != "filename"
|
# async def generate_upload(
|
||||||
) # Swagger 빈 파일 체크
|
# customer_name: str = Form(..., description="고객명/가게명"),
|
||||||
if f and is_real_file and is_valid_ext and is_not_empty:
|
# region: str = Form(..., description="지역명"),
|
||||||
valid_files.append(f)
|
# attribute: str = Form(..., description="음악 속성 정보 (JSON 문자열)"),
|
||||||
else:
|
# images: list[UploadFile] = File(..., description="이미지 파일 목록"),
|
||||||
skipped_files.append(f.filename or "unknown")
|
# detail_region_info: str | None = Form(None, description="상세 지역 정보"),
|
||||||
|
# session: AsyncSession = Depends(get_session),
|
||||||
# 유효한 데이터가 하나도 없으면 에러
|
# ):
|
||||||
if not url_images and not valid_files:
|
# """파일 업로드 기반 영상 생성 요청 처리"""
|
||||||
raise HTTPException(
|
# # attribute JSON 파싱 및 검증
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
# try:
|
||||||
detail=f"유효한 이미지가 없습니다. 지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. 건너뛴 파일: {skipped_files}",
|
# attribute_dict = json.loads(attribute)
|
||||||
)
|
# attribute_info = AttributeInfo(**attribute_dict)
|
||||||
|
# except json.JSONDecodeError:
|
||||||
result_images: list[ImageUploadResultItem] = []
|
# raise HTTPException(
|
||||||
img_order = 0
|
# status_code=400, detail="attribute는 유효한 JSON 형식이어야 합니다."
|
||||||
|
# )
|
||||||
# 1. URL 이미지 저장
|
# except Exception as e:
|
||||||
for url_item in url_images:
|
# raise HTTPException(status_code=400, detail=f"attribute 검증 실패: {e}")
|
||||||
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
|
|
||||||
|
# # 이미지 파일 검증
|
||||||
image = Image(
|
# if not images:
|
||||||
task_id=task_id,
|
# raise HTTPException(
|
||||||
img_name=img_name,
|
# status_code=400, detail="최소 1개 이상의 이미지 파일이 필요합니다."
|
||||||
img_url=url_item.url,
|
# )
|
||||||
img_order=img_order,
|
|
||||||
)
|
# # UUID7 생성 및 중복 검사
|
||||||
session.add(image)
|
# while True:
|
||||||
await session.flush() # ID 생성을 위해 flush
|
# task_id = str(uuid7())
|
||||||
|
# existing = await session.execute(
|
||||||
result_images.append(
|
# select(Project).where(Project.task_id == task_id)
|
||||||
ImageUploadResultItem(
|
# )
|
||||||
id=image.id,
|
# if existing.scalar_one_or_none() is None:
|
||||||
img_name=img_name,
|
# break
|
||||||
img_url=url_item.url,
|
|
||||||
img_order=img_order,
|
# # 저장 경로 생성: media/날짜/task_id/
|
||||||
source="url",
|
# today = date.today().strftime("%Y%m%d")
|
||||||
)
|
# upload_dir = MEDIA_ROOT / today / task_id
|
||||||
)
|
|
||||||
img_order += 1
|
# # Project 생성 (이미지 정보 제외)
|
||||||
|
# project = Project(
|
||||||
# 2. 바이너리 파일을 media에 저장
|
# store_name=customer_name,
|
||||||
if valid_files:
|
# region=region,
|
||||||
today = date.today().strftime("%Y-%m-%d")
|
# task_id=task_id,
|
||||||
# 한 번의 요청에서 업로드된 모든 이미지는 같은 폴더에 저장
|
# detail_region_info=json.dumps(
|
||||||
batch_uuid = await generate_task_id()
|
# {
|
||||||
upload_dir = MEDIA_ROOT / "image" / today / batch_uuid
|
# "detail": detail_region_info,
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
# "attribute": attribute_info.model_dump(),
|
||||||
|
# },
|
||||||
for file in valid_files:
|
# ensure_ascii=False,
|
||||||
# 파일명: 원본 파일명 사용 (중복 방지를 위해 순서 추가)
|
# ),
|
||||||
original_name = file.filename or "image"
|
# )
|
||||||
ext = _get_file_extension(file.filename) # type: ignore[arg-type]
|
# session.add(project)
|
||||||
# 파일명에서 확장자 제거 후 순서 추가
|
|
||||||
name_without_ext = (
|
# # 이미지 파일 저장 및 Image 레코드 생성
|
||||||
original_name.rsplit(".", 1)[0]
|
# for idx, file in enumerate(images):
|
||||||
if "." in original_name
|
# # 각 이미지에 고유 UUID7 생성
|
||||||
else original_name
|
# img_uuid = str(uuid7())
|
||||||
)
|
# ext = _get_file_extension(file.filename)
|
||||||
filename = f"{name_without_ext}_{img_order:03d}{ext}"
|
# filename = f"{img_uuid}{ext}"
|
||||||
|
# save_path = upload_dir / filename
|
||||||
save_path = upload_dir / filename
|
|
||||||
|
# # 파일 저장
|
||||||
# media에 파일 저장
|
# await _save_upload_file(file, save_path)
|
||||||
await _save_upload_file(file, save_path)
|
|
||||||
|
# # Image 레코드 생성 (독립 테이블, task_id로 연결)
|
||||||
# media 기준 URL 생성
|
# img_url = f"/media/{today}/{task_id}/{filename}"
|
||||||
img_url = f"/media/image/{today}/{batch_uuid}/{filename}"
|
# image = Image(
|
||||||
img_name = file.filename or filename
|
# task_id=task_id,
|
||||||
|
# img_name=file.filename or filename,
|
||||||
image = Image(
|
# img_url=img_url,
|
||||||
task_id=task_id,
|
# img_order=idx,
|
||||||
img_name=img_name,
|
# )
|
||||||
img_url=img_url, # Media URL을 DB에 저장
|
# session.add(image)
|
||||||
img_order=img_order,
|
|
||||||
)
|
# await session.commit()
|
||||||
session.add(image)
|
|
||||||
await session.flush()
|
# return {
|
||||||
|
# "task_id": task_id,
|
||||||
result_images.append(
|
# "status": "processing",
|
||||||
ImageUploadResultItem(
|
# "message": "생성 작업이 시작되었습니다.",
|
||||||
id=image.id,
|
# "uploaded_count": len(images),
|
||||||
img_name=img_name,
|
# }
|
||||||
img_url=img_url,
|
|
||||||
img_order=img_order,
|
|
||||||
source="file",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
img_order += 1
|
|
||||||
|
|
||||||
saved_count = len(result_images)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Image 테이블에서 현재 task_id의 이미지 URL 목록 조회
|
|
||||||
image_urls = [img.img_url for img in result_images]
|
|
||||||
|
|
||||||
return ImageUploadResponse(
|
|
||||||
task_id=task_id,
|
|
||||||
total_count=len(result_images),
|
|
||||||
url_count=len(url_images),
|
|
||||||
file_count=len(valid_files),
|
|
||||||
saved_count=saved_count,
|
|
||||||
images=result_images,
|
|
||||||
image_urls=image_urls,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/image/upload/blob",
|
|
||||||
summary="이미지 업로드 (Azure Blob Storage)",
|
|
||||||
description="""
|
|
||||||
이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다.
|
|
||||||
바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다.
|
|
||||||
|
|
||||||
## 요청 방식
|
|
||||||
multipart/form-data 형식으로 전송합니다.
|
|
||||||
|
|
||||||
## 요청 필드
|
|
||||||
- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택)
|
|
||||||
- **files**: 이미지 바이너리 파일 목록 (선택)
|
|
||||||
|
|
||||||
**주의**: images_json 또는 files 중 최소 하나는 반드시 전달해야 합니다.
|
|
||||||
|
|
||||||
## 지원 이미지 확장자
|
|
||||||
jpg, jpeg, png, webp, heic, heif
|
|
||||||
|
|
||||||
## images_json 예시
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{"url": "https://example.com/image1.jpg"},
|
|
||||||
{"url": "https://example.com/image2.jpg", "name": "외관"}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 바이너리 파일 업로드 테스트 방법
|
|
||||||
|
|
||||||
### cURL로 테스트
|
|
||||||
```bash
|
|
||||||
# 바이너리 파일만 업로드
|
|
||||||
curl -X POST "http://localhost:8000/image/upload/blob" \\
|
|
||||||
-F "files=@/path/to/image1.jpg" \\
|
|
||||||
-F "files=@/path/to/image2.png"
|
|
||||||
|
|
||||||
# URL + 바이너리 파일 동시 업로드
|
|
||||||
curl -X POST "http://localhost:8000/image/upload/blob" \\
|
|
||||||
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
|
|
||||||
-F "files=@/path/to/local_image.jpg"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **task_id**: 새로 생성된 작업 고유 식별자
|
|
||||||
- **total_count**: 총 업로드된 이미지 개수
|
|
||||||
- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장)
|
|
||||||
- **file_count**: 파일로 업로드된 이미지 개수 (Azure Blob Storage에 저장)
|
|
||||||
- **saved_count**: Image 테이블에 저장된 row 수
|
|
||||||
- **images**: 업로드된 이미지 목록
|
|
||||||
- **source**: "url" (외부 URL) 또는 "blob" (Azure Blob Storage)
|
|
||||||
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
|
|
||||||
|
|
||||||
## 저장 경로
|
|
||||||
- 바이너리 파일: Azure Blob Storage ({BASE_URL}/{task_id}/image/{파일명})
|
|
||||||
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
|
|
||||||
""",
|
|
||||||
response_model=ImageUploadResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "이미지 업로드 성공"},
|
|
||||||
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
|
||||||
},
|
|
||||||
tags=["image"],
|
|
||||||
)
|
|
||||||
async def upload_images_blob(
|
|
||||||
images_json: Optional[str] = Form(
|
|
||||||
default=None,
|
|
||||||
description="외부 이미지 URL 목록 (JSON 문자열)",
|
|
||||||
example=IMAGES_JSON_EXAMPLE,
|
|
||||||
),
|
|
||||||
files: Optional[list[UploadFile]] = File(
|
|
||||||
default=None, description="이미지 바이너리 파일 목록"
|
|
||||||
),
|
|
||||||
) -> ImageUploadResponse:
|
|
||||||
"""이미지 업로드 (URL + Azure Blob Storage)
|
|
||||||
|
|
||||||
3단계로 분리하여 세션 점유 시간 최소화:
|
|
||||||
- Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음)
|
|
||||||
- Stage 2: Azure Blob 업로드 (세션 없음)
|
|
||||||
- Stage 3: DB 저장 (새 세션으로 빠르게 처리)
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
request_start = time.perf_counter()
|
|
||||||
|
|
||||||
# task_id 생성
|
|
||||||
task_id = await generate_task_id()
|
|
||||||
print(f"[upload_images_blob] START - task_id: {task_id}")
|
|
||||||
|
|
||||||
# ========== Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) ==========
|
|
||||||
has_images_json = images_json is not None and images_json.strip() != ""
|
|
||||||
has_files = files is not None and len(files) > 0
|
|
||||||
|
|
||||||
if not has_images_json and not has_files:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# images_json 파싱
|
|
||||||
url_images: list[ImageUrlItem] = []
|
|
||||||
if has_images_json and images_json:
|
|
||||||
try:
|
|
||||||
parsed = json.loads(images_json)
|
|
||||||
if isinstance(parsed, list):
|
|
||||||
url_images = [ImageUrlItem(**item) for item in parsed if item]
|
|
||||||
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"images_json 파싱 오류: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 유효한 파일만 필터링 및 파일 내용 미리 읽기
|
|
||||||
valid_files_data: list[tuple[str, str, bytes]] = [] # (original_name, ext, content)
|
|
||||||
skipped_files: list[str] = []
|
|
||||||
if has_files and files:
|
|
||||||
for f in files:
|
|
||||||
is_valid_ext = _is_valid_image_extension(f.filename)
|
|
||||||
is_not_empty = f.size is None or f.size > 0
|
|
||||||
is_real_file = f.filename and f.filename != "filename"
|
|
||||||
|
|
||||||
if f and is_real_file and is_valid_ext and is_not_empty:
|
|
||||||
# 파일 내용을 미리 읽어둠
|
|
||||||
content = await f.read()
|
|
||||||
ext = _get_file_extension(f.filename) # type: ignore[arg-type]
|
|
||||||
valid_files_data.append((f.filename or "image", ext, content))
|
|
||||||
else:
|
|
||||||
skipped_files.append(f.filename or "unknown")
|
|
||||||
|
|
||||||
if not url_images and not valid_files_data:
|
|
||||||
detail = (
|
|
||||||
f"유효한 이미지가 없습니다. "
|
|
||||||
f"지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. "
|
|
||||||
f"건너뛴 파일: {skipped_files}"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=detail,
|
|
||||||
)
|
|
||||||
|
|
||||||
stage1_time = time.perf_counter()
|
|
||||||
print(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
|
|
||||||
f"files: {len(valid_files_data)}, "
|
|
||||||
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms")
|
|
||||||
|
|
||||||
# ========== Stage 2: Azure Blob 업로드 (세션 없음) ==========
|
|
||||||
# 업로드 결과를 저장할 리스트 (나중에 DB에 저장)
|
|
||||||
blob_upload_results: list[tuple[str, str]] = [] # (img_name, blob_url)
|
|
||||||
img_order = len(url_images) # URL 이미지 다음 순서부터 시작
|
|
||||||
|
|
||||||
if valid_files_data:
|
|
||||||
uploader = AzureBlobUploader(task_id=task_id)
|
|
||||||
total_files = len(valid_files_data)
|
|
||||||
|
|
||||||
for idx, (original_name, ext, file_content) in enumerate(valid_files_data):
|
|
||||||
name_without_ext = (
|
|
||||||
original_name.rsplit(".", 1)[0]
|
|
||||||
if "." in original_name
|
|
||||||
else original_name
|
|
||||||
)
|
|
||||||
filename = f"{name_without_ext}_{img_order:03d}{ext}"
|
|
||||||
|
|
||||||
print(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: "
|
|
||||||
f"{filename} ({len(file_content)} bytes)")
|
|
||||||
|
|
||||||
# Azure Blob Storage에 직접 업로드
|
|
||||||
upload_success = await uploader.upload_image_bytes(file_content, filename)
|
|
||||||
|
|
||||||
if upload_success:
|
|
||||||
blob_url = uploader.public_url
|
|
||||||
blob_upload_results.append((original_name, blob_url))
|
|
||||||
img_order += 1
|
|
||||||
print(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS")
|
|
||||||
else:
|
|
||||||
skipped_files.append(filename)
|
|
||||||
print(f"[upload_images_blob] File {idx+1}/{total_files} FAILED")
|
|
||||||
|
|
||||||
stage2_time = time.perf_counter()
|
|
||||||
print(f"[upload_images_blob] Stage 2 done - blob uploads: "
|
|
||||||
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
|
|
||||||
f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms")
|
|
||||||
|
|
||||||
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
|
|
||||||
print("[upload_images_blob] Stage 3 starting - DB save...")
|
|
||||||
result_images: list[ImageUploadResultItem] = []
|
|
||||||
img_order = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
# URL 이미지 저장
|
|
||||||
for url_item in url_images:
|
|
||||||
img_name = (
|
|
||||||
url_item.name or _extract_image_name(url_item.url, img_order)
|
|
||||||
)
|
|
||||||
|
|
||||||
image = Image(
|
|
||||||
task_id=task_id,
|
|
||||||
img_name=img_name,
|
|
||||||
img_url=url_item.url,
|
|
||||||
img_order=img_order,
|
|
||||||
)
|
|
||||||
session.add(image)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
result_images.append(
|
|
||||||
ImageUploadResultItem(
|
|
||||||
id=image.id,
|
|
||||||
img_name=img_name,
|
|
||||||
img_url=url_item.url,
|
|
||||||
img_order=img_order,
|
|
||||||
source="url",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
img_order += 1
|
|
||||||
|
|
||||||
# Blob 업로드 결과 저장
|
|
||||||
for img_name, blob_url in blob_upload_results:
|
|
||||||
image = Image(
|
|
||||||
task_id=task_id,
|
|
||||||
img_name=img_name,
|
|
||||||
img_url=blob_url,
|
|
||||||
img_order=img_order,
|
|
||||||
)
|
|
||||||
session.add(image)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
result_images.append(
|
|
||||||
ImageUploadResultItem(
|
|
||||||
id=image.id,
|
|
||||||
img_name=img_name,
|
|
||||||
img_url=blob_url,
|
|
||||||
img_order=img_order,
|
|
||||||
source="blob",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
img_order += 1
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
stage3_time = time.perf_counter()
|
|
||||||
print(f"[upload_images_blob] Stage 3 done - "
|
|
||||||
f"saved: {len(result_images)}, "
|
|
||||||
f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[upload_images_blob] Stage 3 EXCEPTION - "
|
|
||||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
saved_count = len(result_images)
|
|
||||||
image_urls = [img.img_url for img in result_images]
|
|
||||||
|
|
||||||
total_time = time.perf_counter() - request_start
|
|
||||||
print(f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
|
||||||
f"total: {saved_count}, total_time: {total_time*1000:.1f}ms")
|
|
||||||
|
|
||||||
return ImageUploadResponse(
|
|
||||||
task_id=task_id,
|
|
||||||
total_count=len(result_images),
|
|
||||||
url_count=len(url_images),
|
|
||||||
file_count=len(blob_upload_results),
|
|
||||||
saved_count=saved_count,
|
|
||||||
images=result_images,
|
|
||||||
image_urls=image_urls,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -159,102 +159,3 @@ class ErrorResponse(BaseModel):
|
||||||
error_code: str = Field(..., description="에러 코드")
|
error_code: str = Field(..., description="에러 코드")
|
||||||
message: str = Field(..., description="에러 메시지")
|
message: str = Field(..., description="에러 메시지")
|
||||||
detail: Optional[str] = Field(None, description="상세 에러 정보")
|
detail: Optional[str] = Field(None, description="상세 에러 정보")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Image Upload Schemas
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class ImageUrlItem(BaseModel):
|
|
||||||
"""이미지 URL 아이템 스키마"""
|
|
||||||
|
|
||||||
url: str = Field(..., description="외부 이미지 URL")
|
|
||||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadRequest(BaseModel):
|
|
||||||
"""이미지 업로드 요청 스키마 (JSON body 부분)
|
|
||||||
|
|
||||||
URL 이미지 목록을 전달합니다.
|
|
||||||
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"images": [
|
|
||||||
{"url": "https://example.com/images/image_001.jpg"},
|
|
||||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
images: Optional[list[ImageUrlItem]] = Field(
|
|
||||||
None, description="외부 이미지 URL 목록"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadResultItem(BaseModel):
|
|
||||||
"""업로드된 이미지 결과 아이템"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="이미지 ID")
|
|
||||||
img_name: str = Field(..., description="이미지명")
|
|
||||||
img_url: str = Field(..., description="이미지 URL")
|
|
||||||
img_order: int = Field(..., description="이미지 순서")
|
|
||||||
source: Literal["url", "file", "blob"] = Field(
|
|
||||||
..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ImageUploadResponse(BaseModel):
|
|
||||||
"""이미지 업로드 응답 스키마"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"total_count": 3,
|
|
||||||
"url_count": 2,
|
|
||||||
"file_count": 1,
|
|
||||||
"saved_count": 3,
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"img_name": "외관",
|
|
||||||
"img_url": "https://example.com/images/image_001.jpg",
|
|
||||||
"img_order": 0,
|
|
||||||
"source": "url",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"img_name": "내부",
|
|
||||||
"img_url": "https://example.com/images/image_002.jpg",
|
|
||||||
"img_order": 1,
|
|
||||||
"source": "url",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"img_name": "uploaded_image.jpg",
|
|
||||||
"img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
|
||||||
"img_order": 2,
|
|
||||||
"source": "file",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"image_urls": [
|
|
||||||
"https://example.com/images/image_001.jpg",
|
|
||||||
"https://example.com/images/image_002.jpg",
|
|
||||||
"/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)")
|
|
||||||
total_count: int = Field(..., description="총 업로드된 이미지 개수")
|
|
||||||
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
|
|
||||||
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
|
|
||||||
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
|
|
||||||
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
|
|
||||||
image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
"""
|
|
||||||
Home Worker 모듈
|
|
||||||
|
|
||||||
이미지 업로드 관련 백그라운드 작업을 처리합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
from fastapi import UploadFile
|
|
||||||
|
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
|
||||||
|
|
||||||
MEDIA_ROOT = Path("media")
|
|
||||||
|
|
||||||
|
|
||||||
async def save_upload_file(file: UploadFile, save_path: Path) -> None:
|
|
||||||
"""업로드 파일을 지정된 경로에 저장"""
|
|
||||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
async with aiofiles.open(save_path, "wb") as f:
|
|
||||||
content = await file.read()
|
|
||||||
await f.write(content)
|
|
||||||
|
|
||||||
|
|
||||||
async def upload_image_to_blob(
|
|
||||||
task_id: str,
|
|
||||||
file: UploadFile,
|
|
||||||
filename: str,
|
|
||||||
save_dir: Path,
|
|
||||||
) -> tuple[bool, str, str]:
|
|
||||||
"""
|
|
||||||
이미지 파일을 media에 저장하고 Azure Blob Storage에 업로드합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 작업 고유 식별자
|
|
||||||
file: 업로드할 파일 객체
|
|
||||||
filename: 저장될 파일명
|
|
||||||
save_dir: media 저장 디렉토리 경로
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple[bool, str, str]: (업로드 성공 여부, blob_url 또는 에러 메시지, media_path)
|
|
||||||
"""
|
|
||||||
save_path = save_dir / filename
|
|
||||||
media_path = str(save_path)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. media에 파일 저장
|
|
||||||
await save_upload_file(file, save_path)
|
|
||||||
|
|
||||||
# 2. Azure Blob Storage에 업로드
|
|
||||||
uploader = AzureBlobUploader(task_id=task_id)
|
|
||||||
upload_success = await uploader.upload_image(file_path=str(save_path))
|
|
||||||
|
|
||||||
if upload_success:
|
|
||||||
return True, uploader.public_url, media_path
|
|
||||||
else:
|
|
||||||
return False, f"Failed to upload {filename} to Blob", media_path
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return False, str(e), media_path
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database.session import get_worker_session
|
||||||
|
from app.home.schemas.home import GenerateRequest
|
||||||
|
from app.lyric.models import Lyric
|
||||||
|
from app.utils.chatgpt_prompt import ChatgptService
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_lyric(task_id: str, project_id: int, lyric_prompt: str) -> int:
|
||||||
|
"""Lyric 레코드를 DB에 저장 (status=processing, lyric_result=null)"""
|
||||||
|
async with get_worker_session() as session:
|
||||||
|
lyric = Lyric(
|
||||||
|
task_id=task_id,
|
||||||
|
project_id=project_id,
|
||||||
|
status="processing",
|
||||||
|
lyric_prompt=lyric_prompt,
|
||||||
|
lyric_result=None,
|
||||||
|
)
|
||||||
|
session.add(lyric)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(lyric)
|
||||||
|
print(f"Lyric saved: id={lyric.id}, task_id={task_id}, status=processing")
|
||||||
|
return lyric.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_lyric_status(lyric_id: int, status: str, lyric_result: str | None = None) -> None:
|
||||||
|
"""Lyric 레코드의 status와 lyric_result를 업데이트"""
|
||||||
|
async with get_worker_session() as session:
|
||||||
|
result = await session.execute(select(Lyric).where(Lyric.id == lyric_id))
|
||||||
|
lyric = result.scalar_one_or_none()
|
||||||
|
if lyric:
|
||||||
|
lyric.status = status
|
||||||
|
if lyric_result is not None:
|
||||||
|
lyric.lyric_result = lyric_result
|
||||||
|
await session.commit()
|
||||||
|
print(f"Lyric updated: id={lyric_id}, status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
async def lyric_task(
|
||||||
|
task_id: str,
|
||||||
|
project_id: int,
|
||||||
|
customer_name: str,
|
||||||
|
region: str,
|
||||||
|
detail_region_info: str,
|
||||||
|
language: str = "Korean",
|
||||||
|
) -> None:
|
||||||
|
"""가사 생성 작업: ChatGPT로 가사 생성 및 Lyric 테이블 저장/업데이트"""
|
||||||
|
service = ChatgptService(
|
||||||
|
customer_name=customer_name,
|
||||||
|
region=region,
|
||||||
|
detail_region_info=detail_region_info,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lyric 레코드 저장 (status=processing, lyric_result=null)
|
||||||
|
lyric_prompt = service.build_lyrics_prompt()
|
||||||
|
lyric_id = await _save_lyric(task_id, project_id, lyric_prompt)
|
||||||
|
|
||||||
|
# GPT 호출
|
||||||
|
result = await service.generate(prompt=lyric_prompt)
|
||||||
|
|
||||||
|
print(f"GPT Response:\n{result}")
|
||||||
|
|
||||||
|
# 결과에 ERROR가 포함되어 있으면 status를 failed로 업데이트
|
||||||
|
if "ERROR:" in result:
|
||||||
|
await _update_lyric_status(lyric_id, "failed", lyric_result=result)
|
||||||
|
else:
|
||||||
|
await _update_lyric_status(lyric_id, "completed", lyric_result=result)
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_process_async(request_body: GenerateRequest, task_id: str, project_id: int) -> None:
|
||||||
|
"""백그라운드 작업 처리 (async 버전)"""
|
||||||
|
customer_name = request_body.customer_name
|
||||||
|
region = request_body.region
|
||||||
|
detail_region_info = request_body.detail_region_info or ""
|
||||||
|
language = request_body.language
|
||||||
|
|
||||||
|
print(f"customer_name: {customer_name}")
|
||||||
|
print(f"region: {region}")
|
||||||
|
print(f"detail_region_info: {detail_region_info}")
|
||||||
|
print(f"language: {language}")
|
||||||
|
|
||||||
|
# 가사 생성 작업
|
||||||
|
await lyric_task(task_id, project_id, customer_name, region, detail_region_info, language)
|
||||||
|
|
||||||
|
|
||||||
|
def task_process(request_body: GenerateRequest, task_id: str, project_id: int) -> None:
|
||||||
|
"""백그라운드 작업 처리 함수 (sync wrapper)"""
|
||||||
|
asyncio.run(_task_process_async(request_body, task_id, project_id))
|
||||||
|
|
@ -25,7 +25,9 @@ Lyric API Router
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
@ -39,8 +41,8 @@ from app.lyric.schemas.lyric import (
|
||||||
LyricListItem,
|
LyricListItem,
|
||||||
LyricStatusResponse,
|
LyricStatusResponse,
|
||||||
)
|
)
|
||||||
from app.lyric.worker.lyric_task import generate_lyric_background
|
|
||||||
from app.utils.chatgpt_prompt import ChatgptService
|
from app.utils.chatgpt_prompt import ChatgptService
|
||||||
|
from app.utils.common import generate_task_id
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
|
|
||||||
router = APIRouter(prefix="/lyric", tags=["lyric"])
|
router = APIRouter(prefix="/lyric", tags=["lyric"])
|
||||||
|
|
@ -75,12 +77,7 @@ async def get_lyric_status_by_task_id(
|
||||||
# 완료 처리
|
# 완료 처리
|
||||||
"""
|
"""
|
||||||
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
|
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
|
||||||
result = await session.execute(
|
result = await session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
||||||
select(Lyric)
|
|
||||||
.where(Lyric.task_id == task_id)
|
|
||||||
.order_by(Lyric.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
lyric = result.scalar_one_or_none()
|
lyric = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not lyric:
|
if not lyric:
|
||||||
|
|
@ -128,12 +125,7 @@ async def get_lyric_by_task_id(
|
||||||
lyric = await get_lyric_by_task_id(session, task_id)
|
lyric = await get_lyric_by_task_id(session, task_id)
|
||||||
"""
|
"""
|
||||||
print(f"[get_lyric_by_task_id] START - task_id: {task_id}")
|
print(f"[get_lyric_by_task_id] START - task_id: {task_id}")
|
||||||
result = await session.execute(
|
result = await session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
||||||
select(Lyric)
|
|
||||||
.where(Lyric.task_id == task_id)
|
|
||||||
.order_by(Lyric.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
lyric = result.scalar_one_or_none()
|
lyric = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not lyric:
|
if not lyric:
|
||||||
|
|
@ -165,31 +157,29 @@ async def get_lyric_by_task_id(
|
||||||
summary="가사 생성",
|
summary="가사 생성",
|
||||||
description="""
|
description="""
|
||||||
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
|
||||||
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
|
|
||||||
|
|
||||||
## 요청 필드
|
## 요청 필드
|
||||||
- **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수)
|
|
||||||
- **customer_name**: 고객명/가게명 (필수)
|
- **customer_name**: 고객명/가게명 (필수)
|
||||||
- **region**: 지역명 (필수)
|
- **region**: 지역명 (필수)
|
||||||
- **detail_region_info**: 상세 지역 정보 (선택)
|
- **detail_region_info**: 상세 지역 정보 (선택)
|
||||||
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
|
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **success**: 요청 접수 성공 여부
|
- **success**: 생성 성공 여부
|
||||||
- **task_id**: 작업 고유 식별자
|
- **task_id**: 작업 고유 식별자
|
||||||
- **lyric**: null (백그라운드 처리 중)
|
- **lyric**: 생성된 가사 (성공 시)
|
||||||
- **language**: 가사 언어
|
- **language**: 가사 언어
|
||||||
- **error_message**: 에러 메시지 (요청 접수 실패 시)
|
- **error_message**: 에러 메시지 (실패 시)
|
||||||
|
|
||||||
## 상태 확인
|
## 실패 조건
|
||||||
- GET /lyric/status/{task_id} 로 처리 상태 확인
|
- ChatGPT API 오류
|
||||||
- GET /lyric/{task_id} 로 생성된 가사 조회
|
- ChatGPT 거부 응답 (I'm sorry, I cannot 등)
|
||||||
|
- 응답에 ERROR: 포함
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시
|
||||||
```
|
```
|
||||||
POST /lyric/generate
|
POST /lyric/generate
|
||||||
{
|
{
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
|
|
@ -197,34 +187,42 @@ POST /lyric/generate
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 응답 예시
|
## 응답 예시 (성공)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
"lyric": null,
|
"lyric": "인스타 감성의 스테이 머뭄...",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 응답 예시 (실패)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
|
"lyric": null,
|
||||||
|
"language": "Korean",
|
||||||
|
"error_message": "I'm sorry, I can't comply with that request."
|
||||||
|
}
|
||||||
```
|
```
|
||||||
""",
|
""",
|
||||||
response_model=GenerateLyricResponse,
|
response_model=GenerateLyricResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "가사 생성 요청 접수 성공"},
|
200: {"description": "가사 생성 성공 또는 실패 (success 필드로 구분)"},
|
||||||
500: {"description": "서버 내부 오류"},
|
500: {"description": "서버 내부 오류"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def generate_lyric(
|
async def generate_lyric(
|
||||||
request_body: GenerateLyricRequest,
|
request_body: GenerateLyricRequest,
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> GenerateLyricResponse:
|
) -> GenerateLyricResponse:
|
||||||
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
"""고객 정보를 기반으로 가사를 생성합니다."""
|
||||||
task_id = request_body.task_id
|
task_id = await generate_task_id(session=session, table_name=Project)
|
||||||
print(
|
print(
|
||||||
f"[generate_lyric] START - task_id: {task_id}, "
|
f"[generate_lyric] START - task_id: {task_id}, customer_name: {request_body.customer_name}, region: {request_body.region}"
|
||||||
f"customer_name: {request_body.customer_name}, "
|
|
||||||
f"region: {request_body.region}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -247,10 +245,9 @@ async def generate_lyric(
|
||||||
)
|
)
|
||||||
session.add(project)
|
session.add(project)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(project)
|
await session.refresh(project) # commit 후 project.id 동기화
|
||||||
print(
|
print(
|
||||||
f"[generate_lyric] Project saved - "
|
f"[generate_lyric] Project saved - project_id: {project.id}, task_id: {task_id}"
|
||||||
f"project_id: {project.id}, task_id: {task_id}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Lyric 테이블에 데이터 저장 (status: processing)
|
# 3. Lyric 테이블에 데이터 저장 (status: processing)
|
||||||
|
|
@ -263,31 +260,62 @@ async def generate_lyric(
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
)
|
)
|
||||||
session.add(lyric)
|
session.add(lyric)
|
||||||
await session.commit()
|
await (
|
||||||
await session.refresh(lyric)
|
session.commit()
|
||||||
|
) # processing 상태를 확실히 저장 (다른 트랜잭션에서 조회 가능)
|
||||||
|
await session.refresh(lyric) # commit 후 객체 상태 동기화
|
||||||
print(
|
print(
|
||||||
f"[generate_lyric] Lyric saved (processing) - "
|
f"[generate_lyric] Lyric saved (processing) - lyric_id: {lyric.id}, task_id: {task_id}"
|
||||||
f"lyric_id: {lyric.id}, task_id: {task_id}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. 백그라운드 태스크로 ChatGPT 가사 생성 실행
|
# 4. ChatGPT를 통해 가사 생성
|
||||||
background_tasks.add_task(
|
print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}")
|
||||||
generate_lyric_background,
|
result = await service.generate(prompt=prompt)
|
||||||
task_id=task_id,
|
print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}")
|
||||||
prompt=prompt,
|
|
||||||
language=request_body.language,
|
# 5. 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답)
|
||||||
)
|
failure_patterns = [
|
||||||
print(f"[generate_lyric] Background task scheduled - task_id: {task_id}")
|
"ERROR:",
|
||||||
|
"I'm sorry",
|
||||||
|
"I cannot",
|
||||||
|
"I can't",
|
||||||
|
"I apologize",
|
||||||
|
"I'm unable",
|
||||||
|
"I am unable",
|
||||||
|
"I'm not able",
|
||||||
|
"I am not able",
|
||||||
|
]
|
||||||
|
is_failure = any(
|
||||||
|
pattern.lower() in result.lower() for pattern in failure_patterns
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_failure:
|
||||||
|
print(f"[generate_lyric] FAILED - task_id: {task_id}, error: {result}")
|
||||||
|
lyric.status = "failed"
|
||||||
|
lyric.lyric_result = result
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
# 5. 즉시 응답 반환
|
|
||||||
return GenerateLyricResponse(
|
return GenerateLyricResponse(
|
||||||
success=True,
|
success=False,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
lyric=None,
|
lyric=None,
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
error_message=None,
|
error_message=result,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 6. 성공 시 Lyric 테이블 업데이트 (status: completed)
|
||||||
|
lyric.status = "completed"
|
||||||
|
lyric.lyric_result = result
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
print(f"[generate_lyric] SUCCESS - task_id: {task_id}")
|
||||||
|
return GenerateLyricResponse(
|
||||||
|
success=True,
|
||||||
|
task_id=task_id,
|
||||||
|
lyric=result,
|
||||||
|
language=request_body.language,
|
||||||
|
error_message=None,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}")
|
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ Lyric API Schemas
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class GenerateLyricRequest(BaseModel):
|
class GenerateLyricRequest(BaseModel):
|
||||||
|
|
@ -37,7 +37,6 @@ class GenerateLyricRequest(BaseModel):
|
||||||
|
|
||||||
Example Request:
|
Example Request:
|
||||||
{
|
{
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
|
|
@ -45,21 +44,17 @@ class GenerateLyricRequest(BaseModel):
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = {
|
||||||
json_schema_extra={
|
"json_schema_extra": {
|
||||||
"example": {
|
"example": {
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
task_id: str = Field(
|
|
||||||
..., description="작업 고유 식별자 (이미지 업로드 시 생성된 task_id)"
|
|
||||||
)
|
|
||||||
customer_name: str = Field(..., description="고객명/가게명")
|
customer_name: str = Field(..., description="고객명/가게명")
|
||||||
region: str = Field(..., description="지역명")
|
region: str = Field(..., description="지역명")
|
||||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||||
|
|
@ -81,19 +76,25 @@ class GenerateLyricResponse(BaseModel):
|
||||||
- ChatGPT API 오류
|
- ChatGPT API 오류
|
||||||
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등)
|
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize 등)
|
||||||
- 응답에 ERROR: 포함
|
- 응답에 ERROR: 포함
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
Example Response (Success):
|
||||||
json_schema_extra={
|
{
|
||||||
"example": {
|
"success": true,
|
||||||
"success": True,
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"lyric": "인스타 감성의 스테이 머뭄...",
|
||||||
"lyric": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"error_message": None,
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Example Response (Failure):
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
|
"lyric": null,
|
||||||
|
"language": "Korean",
|
||||||
|
"error_message": "I'm sorry, I can't comply with that request."
|
||||||
}
|
}
|
||||||
)
|
"""
|
||||||
|
|
||||||
success: bool = Field(..., description="생성 성공 여부")
|
success: bool = Field(..., description="생성 성공 여부")
|
||||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
|
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
|
||||||
|
|
@ -108,17 +109,14 @@ class LyricStatusResponse(BaseModel):
|
||||||
Usage:
|
Usage:
|
||||||
GET /lyric/status/{task_id}
|
GET /lyric/status/{task_id}
|
||||||
Returns the current processing status of a lyric generation task.
|
Returns the current processing status of a lyric generation task.
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
Example Response:
|
||||||
json_schema_extra={
|
{
|
||||||
"example": {
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"message": "가사 생성이 완료되었습니다.",
|
"message": "가사 생성이 완료되었습니다."
|
||||||
}
|
}
|
||||||
}
|
"""
|
||||||
)
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||||
|
|
@ -131,21 +129,18 @@ class LyricDetailResponse(BaseModel):
|
||||||
Usage:
|
Usage:
|
||||||
GET /lyric/{task_id}
|
GET /lyric/{task_id}
|
||||||
Returns the generated lyric content for a specific task.
|
Returns the generated lyric content for a specific task.
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
Example Response:
|
||||||
json_schema_extra={
|
{
|
||||||
"example": {
|
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"project_id": 1,
|
"project_id": 1,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...",
|
"lyric_prompt": "...",
|
||||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
"lyric_result": "생성된 가사...",
|
||||||
"created_at": "2024-01-15T12:00:00",
|
"created_at": "2024-01-01T12:00:00"
|
||||||
}
|
}
|
||||||
}
|
"""
|
||||||
)
|
|
||||||
|
|
||||||
id: int = Field(..., description="가사 ID")
|
id: int = Field(..., description="가사 ID")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
|
|
@ -163,18 +158,6 @@ class LyricListItem(BaseModel):
|
||||||
Used as individual items in paginated lyric list responses.
|
Used as individual items in paginated lyric list responses.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"id": 1,
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "completed",
|
|
||||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서...",
|
|
||||||
"created_at": "2024-01-15T12:00:00",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
id: int = Field(..., description="가사 ID")
|
id: int = Field(..., description="가사 ID")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
status: str = Field(..., description="처리 상태")
|
status: str = Field(..., description="처리 상태")
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"""
|
|
||||||
Lyric Background Tasks
|
|
||||||
|
|
||||||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
|
||||||
from app.lyric.models import Lyric
|
|
||||||
from app.utils.chatgpt_prompt import ChatgptService
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_lyric_background(
|
|
||||||
task_id: str,
|
|
||||||
prompt: str,
|
|
||||||
language: str,
|
|
||||||
) -> None:
|
|
||||||
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 프로젝트 task_id
|
|
||||||
prompt: ChatGPT에 전달할 프롬프트
|
|
||||||
language: 가사 언어
|
|
||||||
"""
|
|
||||||
print(f"[generate_lyric_background] START - task_id: {task_id}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# ChatGPT 서비스 초기화 (프롬프트는 이미 생성되어 있음)
|
|
||||||
service = ChatgptService(
|
|
||||||
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
|
||||||
region="",
|
|
||||||
detail_region_info="",
|
|
||||||
language=language,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ChatGPT를 통해 가사 생성
|
|
||||||
print(f"[generate_lyric_background] ChatGPT generation started - task_id: {task_id}")
|
|
||||||
result = await service.generate(prompt=prompt)
|
|
||||||
print(f"[generate_lyric_background] ChatGPT generation completed - task_id: {task_id}")
|
|
||||||
|
|
||||||
# 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답)
|
|
||||||
failure_patterns = [
|
|
||||||
"ERROR:",
|
|
||||||
"I'm sorry",
|
|
||||||
"I cannot",
|
|
||||||
"I can't",
|
|
||||||
"I apologize",
|
|
||||||
"I'm unable",
|
|
||||||
"I am unable",
|
|
||||||
"I'm not able",
|
|
||||||
"I am not able",
|
|
||||||
]
|
|
||||||
is_failure = any(
|
|
||||||
pattern.lower() in result.lower() for pattern in failure_patterns
|
|
||||||
)
|
|
||||||
|
|
||||||
# Lyric 테이블 업데이트 (백그라운드 전용 세션 사용)
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
query_result = await session.execute(
|
|
||||||
select(Lyric)
|
|
||||||
.where(Lyric.task_id == task_id)
|
|
||||||
.order_by(Lyric.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
lyric = query_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if lyric:
|
|
||||||
if is_failure:
|
|
||||||
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, error: {result}")
|
|
||||||
lyric.status = "failed"
|
|
||||||
lyric.lyric_result = result
|
|
||||||
else:
|
|
||||||
print(f"[generate_lyric_background] SUCCESS - task_id: {task_id}")
|
|
||||||
lyric.status = "completed"
|
|
||||||
lyric.lyric_result = result
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
else:
|
|
||||||
print(f"[generate_lyric_background] Lyric NOT FOUND in DB - task_id: {task_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
# 실패 시 Lyric 테이블 업데이트
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
query_result = await session.execute(
|
|
||||||
select(Lyric)
|
|
||||||
.where(Lyric.task_id == task_id)
|
|
||||||
.order_by(Lyric.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
lyric = query_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if lyric:
|
|
||||||
lyric.status = "failed"
|
|
||||||
lyric.lyric_result = f"Error: {str(e)}"
|
|
||||||
await session.commit()
|
|
||||||
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, status updated to failed")
|
|
||||||
|
|
@ -13,7 +13,7 @@ Song API Router
|
||||||
app.include_router(router, prefix="/api/v1")
|
app.include_router(router, prefix="/api/v1")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
@ -32,6 +32,7 @@ from app.song.schemas.song_schema import (
|
||||||
PollingSongResponse,
|
PollingSongResponse,
|
||||||
SongListItem,
|
SongListItem,
|
||||||
)
|
)
|
||||||
|
from app.song.worker.song_task import download_and_save_song
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.pagination import PaginatedResponse
|
||||||
from app.utils.suno import SunoService
|
from app.utils.suno import SunoService
|
||||||
|
|
||||||
|
|
@ -84,42 +85,20 @@ POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
|
||||||
async def generate_song(
|
async def generate_song(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
request_body: GenerateSongRequest,
|
request_body: GenerateSongRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> GenerateSongResponse:
|
) -> GenerateSongResponse:
|
||||||
"""가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다.
|
"""가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다.
|
||||||
|
|
||||||
1. task_id로 Project와 Lyric 조회
|
1. task_id로 Project와 Lyric 조회
|
||||||
2. Song 테이블에 초기 데이터 저장 (status: processing)
|
2. Song 테이블에 초기 데이터 저장 (status: processing)
|
||||||
3. Suno API 호출 (세션 닫힌 상태)
|
3. Suno API 호출
|
||||||
4. suno_task_id 업데이트 후 응답 반환
|
4. suno_task_id 업데이트 후 응답 반환
|
||||||
|
|
||||||
Note: 이 함수는 Depends(get_session)을 사용하지 않고 명시적으로 세션을 관리합니다.
|
|
||||||
외부 API 호출 중 DB 커넥션이 유지되지 않도록 하여 커넥션 타임아웃 문제를 방지합니다.
|
|
||||||
"""
|
"""
|
||||||
import time
|
print(f"[generate_song] START - task_id: {task_id}, genre: {request_body.genre}, language: {request_body.language}")
|
||||||
from app.database.session import AsyncSessionLocal
|
|
||||||
|
|
||||||
request_start = time.perf_counter()
|
|
||||||
print(
|
|
||||||
f"[generate_song] START - task_id: {task_id}, "
|
|
||||||
f"genre: {request_body.genre}, language: {request_body.language}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 외부 API 호출 전에 필요한 데이터를 저장할 변수들
|
|
||||||
project_id: int | None = None
|
|
||||||
lyric_id: int | None = None
|
|
||||||
song_id: int | None = None
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음)
|
|
||||||
# ==========================================================================
|
|
||||||
try:
|
try:
|
||||||
async with AsyncSessionLocal() as session:
|
# 1. task_id로 Project 조회
|
||||||
# Project 조회 (중복 시 최신 것 선택)
|
|
||||||
project_result = await session.execute(
|
project_result = await session.execute(
|
||||||
select(Project)
|
select(Project).where(Project.task_id == task_id)
|
||||||
.where(Project.task_id == task_id)
|
|
||||||
.order_by(Project.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
)
|
||||||
project = project_result.scalar_one_or_none()
|
project = project_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
@ -129,14 +108,11 @@ async def generate_song(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||||
)
|
)
|
||||||
project_id = project.id
|
print(f"[generate_song] Project found - project_id: {project.id}, task_id: {task_id}")
|
||||||
|
|
||||||
# Lyric 조회 (중복 시 최신 것 선택)
|
# 2. task_id로 Lyric 조회
|
||||||
lyric_result = await session.execute(
|
lyric_result = await session.execute(
|
||||||
select(Lyric)
|
select(Lyric).where(Lyric.task_id == task_id)
|
||||||
.where(Lyric.task_id == task_id)
|
|
||||||
.order_by(Lyric.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
)
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
@ -146,23 +122,16 @@ async def generate_song(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
|
||||||
)
|
)
|
||||||
lyric_id = lyric.id
|
print(f"[generate_song] Lyric found - lyric_id: {lyric.id}, task_id: {task_id}")
|
||||||
|
|
||||||
query_time = time.perf_counter()
|
# 3. Song 테이블에 초기 데이터 저장
|
||||||
print(
|
|
||||||
f"[generate_song] Queries completed - task_id: {task_id}, "
|
|
||||||
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
|
||||||
f"elapsed: {(query_time - request_start)*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Song 테이블에 초기 데이터 저장
|
|
||||||
song_prompt = (
|
song_prompt = (
|
||||||
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
|
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
|
||||||
)
|
)
|
||||||
|
|
||||||
song = Song(
|
song = Song(
|
||||||
project_id=project_id,
|
project_id=project.id,
|
||||||
lyric_id=lyric_id,
|
lyric_id=lyric.id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=None,
|
suno_task_id=None,
|
||||||
status="processing",
|
status="processing",
|
||||||
|
|
@ -170,103 +139,21 @@ async def generate_song(
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
)
|
)
|
||||||
session.add(song)
|
session.add(song)
|
||||||
await session.commit()
|
await session.flush() # ID 생성을 위해 flush
|
||||||
song_id = song.id
|
print(f"[generate_song] Song saved (processing) - task_id: {task_id}")
|
||||||
|
|
||||||
stage1_time = time.perf_counter()
|
# 4. Suno API 호출
|
||||||
print(
|
print(f"[generate_song] Suno API generation started - task_id: {task_id}")
|
||||||
f"[generate_song] Stage 1 DONE - Song saved - "
|
|
||||||
f"task_id: {task_id}, song_id: {song_id}, "
|
|
||||||
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
# 세션이 여기서 자동으로 닫힘
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[generate_song] Stage 1 EXCEPTION - "
|
|
||||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
|
||||||
)
|
|
||||||
return GenerateSongResponse(
|
|
||||||
success=False,
|
|
||||||
task_id=task_id,
|
|
||||||
suno_task_id=None,
|
|
||||||
message="노래 생성 요청에 실패했습니다.",
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음)
|
|
||||||
# ==========================================================================
|
|
||||||
stage2_start = time.perf_counter()
|
|
||||||
suno_task_id: str | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(f"[generate_song] Stage 2 START - Suno API - task_id: {task_id}")
|
|
||||||
suno_service = SunoService()
|
suno_service = SunoService()
|
||||||
suno_task_id = await suno_service.generate(
|
suno_task_id = await suno_service.generate(
|
||||||
prompt=request_body.lyrics,
|
prompt=request_body.lyrics,
|
||||||
genre=request_body.genre,
|
genre=request_body.genre,
|
||||||
)
|
)
|
||||||
|
|
||||||
stage2_time = time.perf_counter()
|
# 5. suno_task_id 업데이트
|
||||||
print(
|
song.suno_task_id = suno_task_id
|
||||||
f"[generate_song] Stage 2 DONE - task_id: {task_id}, "
|
await session.commit()
|
||||||
f"suno_task_id: {suno_task_id}, "
|
print(f"[generate_song] SUCCESS - task_id: {task_id}, suno_task_id: {suno_task_id}")
|
||||||
f"elapsed: {(stage2_time - stage2_start)*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[generate_song] Stage 2 EXCEPTION - Suno API failed - "
|
|
||||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
|
||||||
)
|
|
||||||
# 외부 API 실패 시 Song 상태를 failed로 업데이트
|
|
||||||
async with AsyncSessionLocal() as update_session:
|
|
||||||
song_result = await update_session.execute(
|
|
||||||
select(Song).where(Song.id == song_id)
|
|
||||||
)
|
|
||||||
song_to_update = song_result.scalar_one_or_none()
|
|
||||||
if song_to_update:
|
|
||||||
song_to_update.status = "failed"
|
|
||||||
await update_session.commit()
|
|
||||||
|
|
||||||
return GenerateSongResponse(
|
|
||||||
success=False,
|
|
||||||
task_id=task_id,
|
|
||||||
suno_task_id=None,
|
|
||||||
message="노래 생성 요청에 실패했습니다.",
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 3단계: suno_task_id 업데이트 (새 세션으로 빠르게 처리)
|
|
||||||
# ==========================================================================
|
|
||||||
stage3_start = time.perf_counter()
|
|
||||||
print(f"[generate_song] Stage 3 START - DB update - task_id: {task_id}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with AsyncSessionLocal() as update_session:
|
|
||||||
song_result = await update_session.execute(
|
|
||||||
select(Song).where(Song.id == song_id)
|
|
||||||
)
|
|
||||||
song_to_update = song_result.scalar_one_or_none()
|
|
||||||
if song_to_update:
|
|
||||||
song_to_update.suno_task_id = suno_task_id
|
|
||||||
await update_session.commit()
|
|
||||||
|
|
||||||
stage3_time = time.perf_counter()
|
|
||||||
total_time = stage3_time - request_start
|
|
||||||
print(
|
|
||||||
f"[generate_song] Stage 3 DONE - task_id: {task_id}, "
|
|
||||||
f"elapsed: {(stage3_time - stage3_start)*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
f"[generate_song] SUCCESS - task_id: {task_id}, "
|
|
||||||
f"suno_task_id: {suno_task_id}, "
|
|
||||||
f"total_time: {total_time*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
|
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=True,
|
success=True,
|
||||||
|
|
@ -276,16 +163,16 @@ async def generate_song(
|
||||||
error_message=None,
|
error_message=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(
|
print(f"[generate_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
f"[generate_song] Stage 3 EXCEPTION - "
|
await session.rollback()
|
||||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
|
||||||
)
|
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=False,
|
success=False,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=suno_task_id,
|
suno_task_id=None,
|
||||||
message="노래 생성은 요청되었으나 DB 업데이트에 실패했습니다.",
|
message="노래 생성 요청에 실패했습니다.",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -295,7 +182,7 @@ async def generate_song(
|
||||||
summary="노래 생성 상태 조회",
|
summary="노래 생성 상태 조회",
|
||||||
description="""
|
description="""
|
||||||
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
|
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
|
||||||
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Song 테이블을 업데이트합니다.
|
||||||
|
|
||||||
## 경로 파라미터
|
## 경로 파라미터
|
||||||
- **suno_task_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수)
|
- **suno_task_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수)
|
||||||
|
|
@ -321,9 +208,7 @@ GET /song/status/abc123...
|
||||||
## 참고
|
## 참고
|
||||||
- 스트림 URL: 30-40초 내 생성
|
- 스트림 URL: 30-40초 내 생성
|
||||||
- 다운로드 URL: 2-3분 내 생성
|
- 다운로드 URL: 2-3분 내 생성
|
||||||
- SUCCESS 시 백그라운드에서 MP3 다운로드 → Azure Blob Storage 업로드 → Song 테이블 업데이트 진행
|
- SUCCESS 시 백그라운드에서 MP3 다운로드 및 DB 업데이트 진행
|
||||||
- 저장 경로: Azure Blob Storage ({BASE_URL}/{task_id}/song/{store_name}.mp3)
|
|
||||||
- Song 테이블의 song_result_url에 Blob URL이 저장됩니다
|
|
||||||
""",
|
""",
|
||||||
response_model=PollingSongResponse,
|
response_model=PollingSongResponse,
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -333,13 +218,13 @@ GET /song/status/abc123...
|
||||||
)
|
)
|
||||||
async def get_song_status(
|
async def get_song_status(
|
||||||
suno_task_id: str,
|
suno_task_id: str,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> PollingSongResponse:
|
) -> PollingSongResponse:
|
||||||
"""suno_task_id로 노래 생성 작업의 상태를 조회합니다.
|
"""suno_task_id로 노래 생성 작업의 상태를 조회합니다.
|
||||||
|
|
||||||
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
|
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
|
||||||
Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로,
|
Song 테이블의 status를 completed로, song_result_url을 업데이트합니다.
|
||||||
song_result_url을 Blob URL로 업데이트합니다.
|
|
||||||
"""
|
"""
|
||||||
print(f"[get_song_status] START - suno_task_id: {suno_task_id}")
|
print(f"[get_song_status] START - suno_task_id: {suno_task_id}")
|
||||||
try:
|
try:
|
||||||
|
|
@ -348,16 +233,14 @@ async def get_song_status(
|
||||||
parsed_response = suno_service.parse_status_response(result)
|
parsed_response = suno_service.parse_status_response(result)
|
||||||
print(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}")
|
print(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}")
|
||||||
|
|
||||||
# SUCCESS 상태인 경우 첫 번째 클립 정보를 DB에 직접 저장
|
# SUCCESS 상태인 경우 백그라운드 태스크 실행
|
||||||
if parsed_response.status == "SUCCESS" and parsed_response.clips:
|
if parsed_response.status == "SUCCESS" and parsed_response.clips:
|
||||||
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
|
# 첫 번째 클립의 audioUrl 가져오기
|
||||||
first_clip = parsed_response.clips[0]
|
first_clip = parsed_response.clips[0]
|
||||||
audio_url = first_clip.audio_url
|
audio_url = first_clip.audio_url
|
||||||
clip_duration = first_clip.duration
|
|
||||||
print(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_duration}")
|
|
||||||
|
|
||||||
if audio_url:
|
if audio_url:
|
||||||
# suno_task_id로 Song 조회
|
# suno_task_id로 Song 조회하여 task_id 가져오기 (여러 개 있을 경우 가장 최근 것 선택)
|
||||||
song_result = await session.execute(
|
song_result = await session.execute(
|
||||||
select(Song)
|
select(Song)
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
.where(Song.suno_task_id == suno_task_id)
|
||||||
|
|
@ -366,16 +249,23 @@ async def get_song_status(
|
||||||
)
|
)
|
||||||
song = song_result.scalar_one_or_none()
|
song = song_result.scalar_one_or_none()
|
||||||
|
|
||||||
if song and song.status != "completed":
|
if song:
|
||||||
# 첫 번째 클립의 audio_url과 duration을 직접 DB에 저장
|
# task_id로 Project 조회하여 store_name 가져오기
|
||||||
song.status = "completed"
|
project_result = await session.execute(
|
||||||
song.song_result_url = audio_url
|
select(Project).where(Project.id == song.project_id)
|
||||||
if clip_duration is not None:
|
)
|
||||||
song.duration = clip_duration
|
project = project_result.scalar_one_or_none()
|
||||||
await session.commit()
|
|
||||||
print(f"[get_song_status] Song updated - suno_task_id: {suno_task_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}")
|
store_name = project.store_name if project else "song"
|
||||||
elif song and song.status == "completed":
|
|
||||||
print(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}")
|
# 백그라운드 태스크로 MP3 다운로드 및 DB 업데이트
|
||||||
|
print(f"[get_song_status] Background task args - task_id: {song.task_id}, audio_url: {audio_url}, store_name: {store_name}")
|
||||||
|
background_tasks.add_task(
|
||||||
|
download_and_save_song,
|
||||||
|
task_id=song.task_id,
|
||||||
|
audio_url=audio_url,
|
||||||
|
store_name=store_name,
|
||||||
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -396,9 +286,9 @@ async def get_song_status(
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/download/{task_id}",
|
"/download/{task_id}",
|
||||||
summary="노래 생성 URL 조회",
|
summary="노래 다운로드 상태 조회",
|
||||||
description="""
|
description="""
|
||||||
task_id를 기반으로 Song 테이블의 상태를 조회하고,
|
task_id를 기반으로 Song 테이블의 상태를 polling하고,
|
||||||
completed인 경우 Project 정보와 노래 URL을 반환합니다.
|
completed인 경우 Project 정보와 노래 URL을 반환합니다.
|
||||||
|
|
||||||
## 경로 파라미터
|
## 경로 파라미터
|
||||||
|
|
@ -406,14 +296,14 @@ completed인 경우 Project 정보와 노래 URL을 반환합니다.
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **success**: 조회 성공 여부
|
- **success**: 조회 성공 여부
|
||||||
- **status**: 처리 상태 (processing, completed, failed, not_found)
|
- **status**: 처리 상태 (processing, completed, failed)
|
||||||
- **message**: 응답 메시지
|
- **message**: 응답 메시지
|
||||||
- **store_name**: 업체명
|
- **store_name**: 업체명
|
||||||
- **region**: 지역명
|
- **region**: 지역명
|
||||||
- **detail_region_info**: 상세 지역 정보
|
- **detail_region_info**: 상세 지역 정보
|
||||||
- **task_id**: 작업 고유 식별자
|
- **task_id**: 작업 고유 식별자
|
||||||
- **language**: 언어
|
- **language**: 언어
|
||||||
- **song_result_url**: 노래 결과 URL (completed 시, Azure Blob Storage URL)
|
- **song_result_url**: 노래 결과 URL (completed 시)
|
||||||
- **created_at**: 생성 일시
|
- **created_at**: 생성 일시
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시
|
||||||
|
|
@ -423,8 +313,7 @@ GET /song/download/019123ab-cdef-7890-abcd-ef1234567890
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- processing 상태인 경우 song_result_url은 null입니다.
|
- processing 상태인 경우 song_result_url은 null입니다.
|
||||||
- completed 상태인 경우 Project 정보와 함께 song_result_url (Azure Blob URL)을 반환합니다.
|
- completed 상태인 경우 Project 정보와 함께 song_result_url을 반환합니다.
|
||||||
- song_result_url 형식: {AZURE_BLOB_BASE_URL}/{task_id}/song/{store_name}.mp3
|
|
||||||
""",
|
""",
|
||||||
response_model=DownloadSongResponse,
|
response_model=DownloadSongResponse,
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -554,36 +443,23 @@ async def get_songs(
|
||||||
try:
|
try:
|
||||||
offset = (pagination.page - 1) * pagination.page_size
|
offset = (pagination.page - 1) * pagination.page_size
|
||||||
|
|
||||||
# 서브쿼리: task_id별 최신 Song의 id 조회 (completed 상태, created_at 기준)
|
# 서브쿼리: task_id별 최신 Song의 id 조회 (completed 상태만)
|
||||||
from sqlalchemy import and_
|
subquery = (
|
||||||
|
select(func.max(Song.id).label("max_id"))
|
||||||
# task_id별 최신 created_at 조회
|
|
||||||
latest_subquery = (
|
|
||||||
select(
|
|
||||||
Song.task_id,
|
|
||||||
func.max(Song.created_at).label("max_created_at")
|
|
||||||
)
|
|
||||||
.where(Song.status == "completed")
|
.where(Song.status == "completed")
|
||||||
.group_by(Song.task_id)
|
.group_by(Song.task_id)
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 전체 개수 조회 (task_id별 최신 1개만)
|
# 전체 개수 조회 (task_id별 최신 1개만)
|
||||||
count_query = select(func.count()).select_from(latest_subquery)
|
count_query = select(func.count()).select_from(subquery)
|
||||||
total_result = await session.execute(count_query)
|
total_result = await session.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
# 데이터 조회 (completed 상태, task_id별 created_at 기준 최신 1개만, 최신순)
|
# 데이터 조회 (completed 상태, task_id별 최신 1개만, 최신순)
|
||||||
query = (
|
query = (
|
||||||
select(Song)
|
select(Song)
|
||||||
.join(
|
.where(Song.id.in_(select(subquery.c.max_id)))
|
||||||
latest_subquery,
|
|
||||||
and_(
|
|
||||||
Song.task_id == latest_subquery.c.task_id,
|
|
||||||
Song.created_at == latest_subquery.c.max_created_at
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(Song.status == "completed")
|
|
||||||
.order_by(Song.created_at.desc())
|
.order_by(Song.created_at.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(pagination.page_size)
|
.limit(pagination.page_size)
|
||||||
|
|
@ -591,19 +467,14 @@ async def get_songs(
|
||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
songs = result.scalars().all()
|
songs = result.scalars().all()
|
||||||
|
|
||||||
# Project 정보 일괄 조회 (N+1 문제 해결)
|
# Project 정보와 함께 SongListItem으로 변환
|
||||||
project_ids = [s.project_id for s in songs if s.project_id]
|
|
||||||
projects_map: dict = {}
|
|
||||||
if project_ids:
|
|
||||||
projects_result = await session.execute(
|
|
||||||
select(Project).where(Project.id.in_(project_ids))
|
|
||||||
)
|
|
||||||
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
|
||||||
|
|
||||||
# SongListItem으로 변환
|
|
||||||
items = []
|
items = []
|
||||||
for song in songs:
|
for song in songs:
|
||||||
project = projects_map.get(song.project_id)
|
# Project 조회 (song.project_id 직접 사용)
|
||||||
|
project_result = await session.execute(
|
||||||
|
select(Project).where(Project.id == song.project_id)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
|
||||||
item = SongListItem(
|
item = SongListItem(
|
||||||
store_name=project.store_name if project else None,
|
store_name=project.store_name if project else None,
|
||||||
|
|
@ -615,6 +486,13 @@ async def get_songs(
|
||||||
)
|
)
|
||||||
items.append(item)
|
items.append(item)
|
||||||
|
|
||||||
|
# 개별 아이템 로그
|
||||||
|
print(
|
||||||
|
f"[get_songs] Item - store_name: {item.store_name}, region: {item.region}, "
|
||||||
|
f"task_id: {item.task_id}, language: {item.language}, "
|
||||||
|
f"song_result_url: {item.song_result_url}, created_at: {item.created_at}"
|
||||||
|
)
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
response = PaginatedResponse.create(
|
||||||
items=items,
|
items=items,
|
||||||
total=total,
|
total=total,
|
||||||
|
|
|
||||||
|
|
@ -100,11 +100,6 @@ class Song(Base):
|
||||||
comment="노래 결과 URL",
|
comment="노래 결과 URL",
|
||||||
)
|
)
|
||||||
|
|
||||||
duration: Mapped[Optional[float]] = mapped_column(
|
|
||||||
nullable=True,
|
|
||||||
comment="노래 재생 시간 (초)",
|
|
||||||
)
|
|
||||||
|
|
||||||
language: Mapped[str] = mapped_column(
|
language: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,9 @@ import aiofiles
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import AsyncSessionLocal
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
from app.utils.common import generate_task_id
|
from app.utils.common import generate_task_id
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -32,8 +31,8 @@ async def download_and_save_song(
|
||||||
"""
|
"""
|
||||||
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
||||||
try:
|
try:
|
||||||
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
# 저장 경로 생성: media/{날짜}/{uuid7}/{store_name}.mp3
|
||||||
today = date.today().strftime("%Y-%m-%d")
|
today = date.today().isoformat()
|
||||||
unique_id = await generate_task_id()
|
unique_id = await generate_task_id()
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
# 파일명에 사용할 수 없는 문자 제거
|
||||||
safe_store_name = "".join(
|
safe_store_name = "".join(
|
||||||
|
|
@ -43,7 +42,7 @@ async def download_and_save_song(
|
||||||
file_name = f"{safe_store_name}.mp3"
|
file_name = f"{safe_store_name}.mp3"
|
||||||
|
|
||||||
# 절대 경로 생성
|
# 절대 경로 생성
|
||||||
media_dir = Path("media") / "song" / today / unique_id
|
media_dir = Path("media") / today / unique_id
|
||||||
media_dir.mkdir(parents=True, exist_ok=True)
|
media_dir.mkdir(parents=True, exist_ok=True)
|
||||||
file_path = media_dir / file_name
|
file_path = media_dir / file_name
|
||||||
print(f"[download_and_save_song] Directory created - path: {file_path}")
|
print(f"[download_and_save_song] Directory created - path: {file_path}")
|
||||||
|
|
@ -59,13 +58,13 @@ async def download_and_save_song(
|
||||||
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
||||||
|
|
||||||
# 프론트엔드에서 접근 가능한 URL 생성
|
# 프론트엔드에서 접근 가능한 URL 생성
|
||||||
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
relative_path = f"/media/{today}/{unique_id}/{file_name}"
|
||||||
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
|
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
|
||||||
file_url = f"{base_url}{relative_path}"
|
file_url = f"{base_url}{relative_path}"
|
||||||
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
||||||
|
|
||||||
# Song 테이블 업데이트 (새 세션 사용)
|
# Song 테이블 업데이트 (새 세션 사용)
|
||||||
async with BackgroundSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# 여러 개 있을 경우 가장 최근 것 선택
|
# 여러 개 있을 경우 가장 최근 것 선택
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Song)
|
select(Song)
|
||||||
|
|
@ -86,7 +85,7 @@ async def download_and_save_song(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
# 실패 시 Song 테이블 업데이트
|
# 실패 시 Song 테이블 업데이트
|
||||||
async with BackgroundSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# 여러 개 있을 경우 가장 최근 것 선택
|
# 여러 개 있을 경우 가장 최근 것 선택
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Song)
|
select(Song)
|
||||||
|
|
@ -100,234 +99,3 @@ async def download_and_save_song(
|
||||||
song.status = "failed"
|
song.status = "failed"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
print(f"[download_and_save_song] FAILED - task_id: {task_id}, status updated to failed")
|
print(f"[download_and_save_song] FAILED - task_id: {task_id}, status updated to failed")
|
||||||
|
|
||||||
|
|
||||||
async def download_and_upload_song_to_blob(
|
|
||||||
task_id: str,
|
|
||||||
audio_url: str,
|
|
||||||
store_name: str,
|
|
||||||
) -> None:
|
|
||||||
"""백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 프로젝트 task_id
|
|
||||||
audio_url: 다운로드할 오디오 URL
|
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
|
||||||
"""
|
|
||||||
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
|
||||||
temp_file_path: Path | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
|
||||||
safe_store_name = "".join(
|
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
|
||||||
).strip()
|
|
||||||
safe_store_name = safe_store_name or "song"
|
|
||||||
file_name = f"{safe_store_name}.mp3"
|
|
||||||
|
|
||||||
# 임시 저장 경로 생성
|
|
||||||
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_song_to_blob] Temp directory created - path: {temp_file_path}")
|
|
||||||
|
|
||||||
# 오디오 파일 다운로드
|
|
||||||
print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(audio_url, timeout=60.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_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
|
||||||
|
|
||||||
# Azure Blob Storage에 업로드
|
|
||||||
uploader = AzureBlobUploader(task_id=task_id)
|
|
||||||
upload_success = await uploader.upload_music(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_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
|
||||||
|
|
||||||
# Song 테이블 업데이트 (새 세션 사용)
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
# 여러 개 있을 경우 가장 최근 것 선택
|
|
||||||
result = await session.execute(
|
|
||||||
select(Song)
|
|
||||||
.where(Song.task_id == task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if song:
|
|
||||||
song.status = "completed"
|
|
||||||
song.song_result_url = blob_url
|
|
||||||
await session.commit()
|
|
||||||
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}, status: completed")
|
|
||||||
else:
|
|
||||||
print(f"[download_and_upload_song_to_blob] Song NOT FOUND in DB - task_id: {task_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
# 실패 시 Song 테이블 업데이트
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Song)
|
|
||||||
.where(Song.task_id == task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if song:
|
|
||||||
song.status = "failed"
|
|
||||||
await session.commit()
|
|
||||||
print(f"[download_and_upload_song_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_song_to_blob] Temp file deleted - path: {temp_file_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[download_and_upload_song_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_song_by_suno_task_id(
|
|
||||||
suno_task_id: str,
|
|
||||||
audio_url: str,
|
|
||||||
store_name: str,
|
|
||||||
duration: float | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
suno_task_id: Suno API 작업 ID
|
|
||||||
audio_url: 다운로드할 오디오 URL
|
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
|
||||||
duration: 노래 재생 시간 (초)
|
|
||||||
"""
|
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
|
|
||||||
temp_file_path: Path | None = None
|
|
||||||
task_id: str | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# suno_task_id로 Song 조회하여 task_id 가져오기
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Song)
|
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not song:
|
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
task_id = song.task_id
|
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_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 "song"
|
|
||||||
file_name = f"{safe_store_name}.mp3"
|
|
||||||
|
|
||||||
# 임시 저장 경로 생성
|
|
||||||
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_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
|
|
||||||
|
|
||||||
# 오디오 파일 다운로드
|
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(audio_url, timeout=60.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_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
|
|
||||||
|
|
||||||
# Azure Blob Storage에 업로드
|
|
||||||
uploader = AzureBlobUploader(task_id=task_id)
|
|
||||||
upload_success = await uploader.upload_music(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_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
|
|
||||||
|
|
||||||
# Song 테이블 업데이트 (새 세션 사용)
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Song)
|
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if song:
|
|
||||||
song.status = "completed"
|
|
||||||
song.song_result_url = blob_url
|
|
||||||
if duration is not None:
|
|
||||||
song.duration = duration
|
|
||||||
await session.commit()
|
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, status: completed, duration: {duration}")
|
|
||||||
else:
|
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND in DB - suno_task_id: {suno_task_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
|
||||||
# 실패 시 Song 테이블 업데이트
|
|
||||||
if task_id:
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Song)
|
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if song:
|
|
||||||
song.status = "failed"
|
|
||||||
await session.commit()
|
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] FAILED - suno_task_id: {suno_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_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[download_and_upload_song_by_suno_task_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 # 디렉토리가 비어있지 않으면 무시
|
|
||||||
|
|
|
||||||
|
|
@ -13,189 +13,54 @@ creatomate = CreatomateService()
|
||||||
# 또는 명시적으로 API 키 전달
|
# 또는 명시적으로 API 키 전달
|
||||||
creatomate = CreatomateService(api_key="your_api_key")
|
creatomate = CreatomateService(api_key="your_api_key")
|
||||||
|
|
||||||
# 템플릿 목록 조회 (비동기)
|
# 템플릿 목록 조회
|
||||||
templates = await creatomate.get_all_templates_data()
|
templates = creatomate.get_all_templates_data()
|
||||||
|
|
||||||
# 특정 템플릿 조회 (비동기)
|
# 특정 템플릿 조회
|
||||||
template = await creatomate.get_one_template_data(template_id)
|
template = creatomate.get_one_template_data(template_id)
|
||||||
|
|
||||||
# 영상 렌더링 요청 (비동기)
|
# 영상 렌더링 요청
|
||||||
response = await creatomate.make_creatomate_call(template_id, modifications)
|
response = creatomate.make_creatomate_call(template_id, modifications)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 성능 최적화
|
|
||||||
- 템플릿 캐싱: 템플릿 데이터는 메모리에 캐싱되어 반복 조회 시 API 호출을 줄입니다.
|
|
||||||
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀을 재사용합니다.
|
|
||||||
- 캐시 만료: 기본 5분 후 자동 만료 (CACHE_TTL_SECONDS로 조정 가능)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import time
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import apikey_settings, creatomate_settings
|
from config import apikey_settings
|
||||||
|
|
||||||
|
|
||||||
# Orientation 타입 정의
|
|
||||||
OrientationType = Literal["horizontal", "vertical"]
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 모듈 레벨 캐시 및 HTTP 클라이언트 (싱글톤 패턴)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# 템플릿 캐시: {template_id: {"data": dict, "cached_at": float}}
|
|
||||||
_template_cache: dict[str, dict] = {}
|
|
||||||
|
|
||||||
# 캐시 TTL (초) - 기본 5분
|
|
||||||
CACHE_TTL_SECONDS = 300
|
|
||||||
|
|
||||||
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
|
|
||||||
_shared_client: httpx.AsyncClient | None = None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_shared_client() -> httpx.AsyncClient:
|
|
||||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
|
||||||
global _shared_client
|
|
||||||
if _shared_client is None or _shared_client.is_closed:
|
|
||||||
_shared_client = httpx.AsyncClient(
|
|
||||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
|
||||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
|
||||||
)
|
|
||||||
return _shared_client
|
|
||||||
|
|
||||||
|
|
||||||
async def close_shared_client() -> None:
|
|
||||||
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
|
|
||||||
global _shared_client
|
|
||||||
if _shared_client is not None and not _shared_client.is_closed:
|
|
||||||
await _shared_client.aclose()
|
|
||||||
_shared_client = None
|
|
||||||
print("[CreatomateService] Shared HTTP client closed")
|
|
||||||
|
|
||||||
|
|
||||||
def clear_template_cache() -> None:
|
|
||||||
"""템플릿 캐시를 전체 삭제합니다."""
|
|
||||||
global _template_cache
|
|
||||||
_template_cache.clear()
|
|
||||||
print("[CreatomateService] Template cache cleared")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_cache_valid(cached_at: float) -> bool:
|
|
||||||
"""캐시가 유효한지 확인합니다."""
|
|
||||||
return (time.time() - cached_at) < CACHE_TTL_SECONDS
|
|
||||||
|
|
||||||
|
|
||||||
class CreatomateService:
|
class CreatomateService:
|
||||||
"""Creatomate API를 통한 영상 생성 서비스
|
"""Creatomate API를 통한 영상 생성 서비스"""
|
||||||
|
|
||||||
모든 HTTP 호출 메서드는 비동기(async)로 구현되어 있습니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
BASE_URL = "https://api.creatomate.com"
|
BASE_URL = "https://api.creatomate.com"
|
||||||
|
|
||||||
# 템플릿 설정 (config에서 가져옴)
|
def __init__(self, api_key: str | None = None):
|
||||||
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}",
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_all_templates_data(self) -> dict:
|
def get_all_templates_data(self) -> dict:
|
||||||
"""모든 템플릿 정보를 조회합니다."""
|
"""모든 템플릿 정보를 조회합니다."""
|
||||||
url = f"{self.BASE_URL}/v1/templates"
|
url = f"{self.BASE_URL}/v1/templates"
|
||||||
client = await get_shared_client()
|
response = httpx.get(url, headers=self.headers, timeout=30.0)
|
||||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def get_one_template_data(
|
def get_one_template_data(self, template_id: str) -> dict:
|
||||||
self,
|
"""특정 템플릿 ID로 템플릿 정보를 조회합니다."""
|
||||||
template_id: str,
|
|
||||||
use_cache: bool = True,
|
|
||||||
) -> dict:
|
|
||||||
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template_id: 조회할 템플릿 ID
|
|
||||||
use_cache: 캐시 사용 여부 (기본: True)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
템플릿 데이터 (deep copy)
|
|
||||||
"""
|
|
||||||
global _template_cache
|
|
||||||
|
|
||||||
# 캐시 확인
|
|
||||||
if use_cache and template_id in _template_cache:
|
|
||||||
cached = _template_cache[template_id]
|
|
||||||
if _is_cache_valid(cached["cached_at"]):
|
|
||||||
print(f"[CreatomateService] Cache HIT - {template_id}")
|
|
||||||
return copy.deepcopy(cached["data"])
|
|
||||||
else:
|
|
||||||
# 만료된 캐시 삭제
|
|
||||||
del _template_cache[template_id]
|
|
||||||
print(f"[CreatomateService] Cache EXPIRED - {template_id}")
|
|
||||||
|
|
||||||
# API 호출
|
|
||||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||||
client = await get_shared_client()
|
response = httpx.get(url, headers=self.headers, timeout=30.0)
|
||||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
return response.json()
|
||||||
|
|
||||||
# 캐시 저장
|
|
||||||
_template_cache[template_id] = {
|
|
||||||
"data": data,
|
|
||||||
"cached_at": time.time(),
|
|
||||||
}
|
|
||||||
print(f"[CreatomateService] Cache MISS - {template_id} (cached)")
|
|
||||||
|
|
||||||
return copy.deepcopy(data)
|
|
||||||
|
|
||||||
# 하위 호환성을 위한 별칭 (deprecated)
|
|
||||||
async def get_one_template_data_async(self, template_id: str) -> dict:
|
|
||||||
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
|
||||||
|
|
||||||
Deprecated: get_one_template_data()를 사용하세요.
|
|
||||||
"""
|
|
||||||
return await self.get_one_template_data(template_id)
|
|
||||||
|
|
||||||
def parse_template_component_name(self, template_source: list) -> dict:
|
def parse_template_component_name(self, template_source: list) -> dict:
|
||||||
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
||||||
|
|
@ -224,7 +89,7 @@ class CreatomateService:
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def template_connect_resource_blackbox(
|
def template_connect_resource_blackbox(
|
||||||
self,
|
self,
|
||||||
template_id: str,
|
template_id: str,
|
||||||
image_url_list: list[str],
|
image_url_list: list[str],
|
||||||
|
|
@ -238,7 +103,7 @@ class CreatomateService:
|
||||||
- 가사는 개행마다 한 텍스트 삽입
|
- 가사는 개행마다 한 텍스트 삽입
|
||||||
- Template에 audio-music 항목이 있어야 함
|
- Template에 audio-music 항목이 있어야 함
|
||||||
"""
|
"""
|
||||||
template_data = await self.get_one_template_data(template_id)
|
template_data = self.get_one_template_data(template_id)
|
||||||
template_component_data = self.parse_template_component_name(
|
template_component_data = self.parse_template_component_name(
|
||||||
template_data["source"]["elements"]
|
template_data["source"]["elements"]
|
||||||
)
|
)
|
||||||
|
|
@ -318,9 +183,7 @@ class CreatomateService:
|
||||||
|
|
||||||
return elements
|
return elements
|
||||||
|
|
||||||
async def make_creatomate_call(
|
def make_creatomate_call(self, template_id: str, modifications: dict):
|
||||||
self, template_id: str, modifications: dict
|
|
||||||
) -> dict:
|
|
||||||
"""Creatomate에 렌더링 요청을 보냅니다.
|
"""Creatomate에 렌더링 요청을 보냅니다.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|
@ -331,67 +194,21 @@ class CreatomateService:
|
||||||
"template_id": template_id,
|
"template_id": template_id,
|
||||||
"modifications": modifications,
|
"modifications": modifications,
|
||||||
}
|
}
|
||||||
client = await get_shared_client()
|
response = httpx.post(url, json=data, headers=self.headers, timeout=60.0)
|
||||||
response = await client.post(
|
|
||||||
url, json=data, headers=self.headers, timeout=60.0
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def make_creatomate_custom_call(self, source: dict) -> dict:
|
def make_creatomate_custom_call(self, source: dict):
|
||||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
response에 요청 정보가 있으니 폴링 필요
|
response에 요청 정보가 있으니 폴링 필요
|
||||||
"""
|
"""
|
||||||
url = f"{self.BASE_URL}/v2/renders"
|
url = f"{self.BASE_URL}/v2/renders"
|
||||||
client = await get_shared_client()
|
response = httpx.post(url, json=source, headers=self.headers, timeout=60.0)
|
||||||
response = await client.post(
|
|
||||||
url, json=source, headers=self.headers, timeout=60.0
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
# 하위 호환성을 위한 별칭 (deprecated)
|
|
||||||
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
|
|
||||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
|
||||||
|
|
||||||
Deprecated: make_creatomate_custom_call()을 사용하세요.
|
|
||||||
"""
|
|
||||||
return await self.make_creatomate_custom_call(source)
|
|
||||||
|
|
||||||
async def get_render_status(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}"
|
|
||||||
client = await get_shared_client()
|
|
||||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
# 하위 호환성을 위한 별칭 (deprecated)
|
|
||||||
async def get_render_status_async(self, render_id: str) -> dict:
|
|
||||||
"""렌더링 작업의 상태를 조회합니다.
|
|
||||||
|
|
||||||
Deprecated: get_render_status()를 사용하세요.
|
|
||||||
"""
|
|
||||||
return await self.get_render_status(render_id)
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,443 +1,64 @@
|
||||||
"""
|
import requests
|
||||||
Azure Blob Storage 업로드 유틸리티
|
|
||||||
|
|
||||||
Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다.
|
|
||||||
파일 경로 또는 바이트 데이터를 직접 업로드할 수 있습니다.
|
|
||||||
|
|
||||||
URL 경로 형식:
|
|
||||||
- 음악: {BASE_URL}/{task_id}/song/{파일명}
|
|
||||||
- 영상: {BASE_URL}/{task_id}/video/{파일명}
|
|
||||||
- 이미지: {BASE_URL}/{task_id}/image/{파일명}
|
|
||||||
|
|
||||||
사용 예시:
|
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
|
||||||
|
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
|
|
||||||
# 파일 경로로 업로드
|
|
||||||
success = await uploader.upload_music(file_path="my_song.mp3")
|
|
||||||
success = await uploader.upload_video(file_path="my_video.mp4")
|
|
||||||
success = await uploader.upload_image(file_path="my_image.png")
|
|
||||||
|
|
||||||
# 바이트 데이터로 직접 업로드 (media 저장 없이)
|
|
||||||
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
|
||||||
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
|
||||||
success = await uploader.upload_image_bytes(image_bytes, "my_image.png")
|
|
||||||
|
|
||||||
print(uploader.public_url) # 마지막 업로드의 공개 URL
|
|
||||||
|
|
||||||
성능 최적화:
|
|
||||||
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀 재사용
|
|
||||||
- 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
SAS_TOKEN = "sp=racwdl&st=2025-12-01T00:13:29Z&se=2026-07-31T08:28:29Z&spr=https&sv=2024-11-04&sr=c&sig=7fE2ozVBPu3Gq43%2FZDxEYdEcPLDXyNVfTf16IBasmVQ%3D"
|
||||||
import httpx
|
|
||||||
|
|
||||||
from config import azure_blob_settings
|
def upload_music_to_azure_blob(file_path = "스테이 머뭄_1.mp3", url = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/test_my_mp3_should_now_exist.mp3"):
|
||||||
|
access_url = f"{url}?{SAS_TOKEN}"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "audio/mpeg",
|
||||||
|
"x-ms-blob-type": "BlockBlob"
|
||||||
|
}
|
||||||
|
with open(file_path, "rb") as file:
|
||||||
|
response = requests.put(access_url, data=file, headers=headers)
|
||||||
|
if response.status_code in [200, 201]:
|
||||||
|
print(f"Success Status Code: {response.status_code}")
|
||||||
|
else:
|
||||||
|
print(f"Failed Status Code: {response.status_code}")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
|
||||||
|
def upload_video_to_azure_blob(file_path = "스테이 머뭄.mp4", url = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/test_my_mp3_should_now_exist.mp4"):
|
||||||
|
access_url = f"{url}?{SAS_TOKEN}"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "video/mp4",
|
||||||
|
"x-ms-blob-type": "BlockBlob"
|
||||||
|
}
|
||||||
|
with open(file_path, "rb") as file:
|
||||||
|
response = requests.put(access_url, data=file, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code in [200, 201]:
|
||||||
|
print(f"Success Status Code: {response.status_code}")
|
||||||
|
else:
|
||||||
|
print(f"Failed Status Code: {response.status_code}")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
def upload_image_to_azure_blob(file_path = "스테이 머뭄.png", url = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/test_my_mp3_should_now_exist.png"):
|
||||||
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
access_url = f"{url}?{SAS_TOKEN}"
|
||||||
# =============================================================================
|
extension = Path(file_path).suffix.lower()
|
||||||
|
content_types = {
|
||||||
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
|
|
||||||
_shared_blob_client: httpx.AsyncClient | None = None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_shared_blob_client() -> httpx.AsyncClient:
|
|
||||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
|
||||||
global _shared_blob_client
|
|
||||||
if _shared_blob_client is None or _shared_blob_client.is_closed:
|
|
||||||
print("[AzureBlobUploader] Creating shared HTTP client...")
|
|
||||||
_shared_blob_client = httpx.AsyncClient(
|
|
||||||
timeout=httpx.Timeout(180.0, connect=10.0),
|
|
||||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
|
||||||
)
|
|
||||||
print("[AzureBlobUploader] Shared HTTP client created - "
|
|
||||||
"max_connections: 20, max_keepalive: 10")
|
|
||||||
return _shared_blob_client
|
|
||||||
|
|
||||||
|
|
||||||
async def close_shared_blob_client() -> None:
|
|
||||||
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
|
|
||||||
global _shared_blob_client
|
|
||||||
if _shared_blob_client is not None and not _shared_blob_client.is_closed:
|
|
||||||
await _shared_blob_client.aclose()
|
|
||||||
_shared_blob_client = None
|
|
||||||
print("[AzureBlobUploader] Shared HTTP client closed")
|
|
||||||
|
|
||||||
|
|
||||||
class AzureBlobUploader:
|
|
||||||
"""Azure Blob Storage 업로드 클래스
|
|
||||||
|
|
||||||
Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다.
|
|
||||||
URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
|
|
||||||
|
|
||||||
카테고리별 경로:
|
|
||||||
- 음악: {task_id}/song/{file_name}
|
|
||||||
- 영상: {task_id}/video/{file_name}
|
|
||||||
- 이미지: {task_id}/image/{file_name}
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
task_id: 작업 고유 식별자
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Content-Type 매핑
|
|
||||||
IMAGE_CONTENT_TYPES = {
|
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
".bmp": "image/bmp",
|
".bmp": "image/bmp"
|
||||||
}
|
}
|
||||||
|
content_type = content_types.get(extension, "image/jpeg")
|
||||||
def __init__(self, task_id: str):
|
headers = {
|
||||||
"""AzureBlobUploader 초기화
|
"Content-Type": content_type,
|
||||||
|
"x-ms-blob-type": "BlockBlob"
|
||||||
Args:
|
}
|
||||||
task_id: 작업 고유 식별자
|
with open(file_path, "rb") as file:
|
||||||
"""
|
response = requests.put(access_url, data=file, headers=headers)
|
||||||
self._task_id = task_id
|
|
||||||
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL
|
|
||||||
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
|
|
||||||
self._last_public_url: str = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def task_id(self) -> str:
|
|
||||||
"""작업 고유 식별자"""
|
|
||||||
return self._task_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def public_url(self) -> str:
|
|
||||||
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
|
|
||||||
return self._last_public_url
|
|
||||||
|
|
||||||
def _build_upload_url(self, category: str, file_name: str) -> str:
|
|
||||||
"""업로드 URL 생성 (SAS 토큰 포함)"""
|
|
||||||
# SAS 토큰 앞뒤의 ?, ', " 제거
|
|
||||||
sas_token = self._sas_token.strip("?'\"")
|
|
||||||
return (
|
|
||||||
f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_public_url(self, category: str, file_name: str) -> str:
|
|
||||||
"""공개 URL 생성 (SAS 토큰 제외)"""
|
|
||||||
return f"{self._base_url}/{self._task_id}/{category}/{file_name}"
|
|
||||||
|
|
||||||
async def _upload_bytes(
|
|
||||||
self,
|
|
||||||
file_content: bytes,
|
|
||||||
upload_url: str,
|
|
||||||
headers: dict,
|
|
||||||
timeout: float,
|
|
||||||
log_prefix: str,
|
|
||||||
) -> bool:
|
|
||||||
"""바이트 데이터를 업로드하는 공통 내부 메서드"""
|
|
||||||
start_time = time.perf_counter()
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(f"[{log_prefix}] Getting shared client...")
|
|
||||||
client = await get_shared_blob_client()
|
|
||||||
client_time = time.perf_counter()
|
|
||||||
elapsed_ms = (client_time - start_time) * 1000
|
|
||||||
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
|
|
||||||
|
|
||||||
size = len(file_content)
|
|
||||||
print(f"[{log_prefix}] Starting upload... "
|
|
||||||
f"(size: {size} bytes, timeout: {timeout}s)")
|
|
||||||
|
|
||||||
response = await asyncio.wait_for(
|
|
||||||
client.put(upload_url, content=file_content, headers=headers),
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
upload_time = time.perf_counter()
|
|
||||||
duration_ms = (upload_time - start_time) * 1000
|
|
||||||
|
|
||||||
if response.status_code in [200, 201]:
|
if response.status_code in [200, 201]:
|
||||||
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
|
print(f"Success Status Code: {response.status_code}")
|
||||||
f"Duration: {duration_ms:.1f}ms")
|
|
||||||
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
|
|
||||||
return True
|
|
||||||
else:
|
else:
|
||||||
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
print(f"Failed Status Code: {response.status_code}")
|
||||||
f"Duration: {duration_ms:.1f}ms")
|
print(f"Response: {response.text}")
|
||||||
print(f"[{log_prefix}] Response: {response.text[:500]}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
elapsed = time.perf_counter() - start_time
|
|
||||||
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s "
|
|
||||||
f"(limit: {timeout}s)")
|
|
||||||
return False
|
|
||||||
except httpx.ConnectError as e:
|
|
||||||
elapsed = time.perf_counter() - start_time
|
|
||||||
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
|
|
||||||
f"{type(e).__name__}: {e}")
|
|
||||||
return False
|
|
||||||
except httpx.ReadError as e:
|
|
||||||
elapsed = time.perf_counter() - start_time
|
|
||||||
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
|
|
||||||
f"{type(e).__name__}: {e}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
elapsed = time.perf_counter() - start_time
|
|
||||||
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
|
||||||
f"{type(e).__name__}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _upload_file(
|
|
||||||
self,
|
|
||||||
file_path: str,
|
|
||||||
category: str,
|
|
||||||
content_type: str,
|
|
||||||
timeout: float,
|
|
||||||
log_prefix: str,
|
|
||||||
) -> bool:
|
|
||||||
"""파일을 Azure Blob Storage에 업로드하는 내부 메서드
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: 업로드할 파일 경로
|
|
||||||
category: 카테고리 (song, video, image)
|
|
||||||
content_type: Content-Type 헤더 값
|
|
||||||
timeout: 요청 타임아웃 (초)
|
|
||||||
log_prefix: 로그 접두사
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 업로드 성공 여부
|
|
||||||
"""
|
|
||||||
# 파일 경로에서 파일명 추출
|
|
||||||
file_name = Path(file_path).name
|
|
||||||
|
|
||||||
upload_url = self._build_upload_url(category, file_name)
|
|
||||||
self._last_public_url = self._build_public_url(category, file_name)
|
|
||||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
|
||||||
|
|
||||||
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
|
||||||
|
|
||||||
async with aiofiles.open(file_path, "rb") as file:
|
|
||||||
file_content = await file.read()
|
|
||||||
|
|
||||||
return await self._upload_bytes(
|
|
||||||
file_content=file_content,
|
|
||||||
upload_url=upload_url,
|
|
||||||
headers=headers,
|
|
||||||
timeout=timeout,
|
|
||||||
log_prefix=log_prefix,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def upload_music(self, file_path: str) -> bool:
|
|
||||||
"""음악 파일을 Azure Blob Storage에 업로드합니다.
|
|
||||||
|
|
||||||
URL 경로: {task_id}/song/{파일명}
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: 업로드할 파일 경로
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 업로드 성공 여부
|
|
||||||
|
|
||||||
Example:
|
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
success = await uploader.upload_music(file_path="my_song.mp3")
|
|
||||||
print(uploader.public_url)
|
|
||||||
"""
|
|
||||||
return await self._upload_file(
|
|
||||||
file_path=file_path,
|
|
||||||
category="song",
|
|
||||||
content_type="audio/mpeg",
|
|
||||||
timeout=120.0,
|
|
||||||
log_prefix="upload_music",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def upload_music_bytes(
|
|
||||||
self, file_content: bytes, file_name: str
|
|
||||||
) -> bool:
|
|
||||||
"""음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
|
||||||
|
|
||||||
URL 경로: {task_id}/song/{파일명}
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_content: 업로드할 파일 바이트 데이터
|
|
||||||
file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 업로드 성공 여부
|
|
||||||
|
|
||||||
Example:
|
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
|
||||||
print(uploader.public_url)
|
|
||||||
"""
|
|
||||||
# 확장자가 없으면 .mp3 추가
|
|
||||||
if not Path(file_name).suffix:
|
|
||||||
file_name = f"{file_name}.mp3"
|
|
||||||
|
|
||||||
upload_url = self._build_upload_url("song", file_name)
|
|
||||||
self._last_public_url = self._build_public_url("song", file_name)
|
|
||||||
log_prefix = "upload_music_bytes"
|
|
||||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
|
||||||
|
|
||||||
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
|
|
||||||
|
|
||||||
return await self._upload_bytes(
|
|
||||||
file_content=file_content,
|
|
||||||
upload_url=upload_url,
|
|
||||||
headers=headers,
|
|
||||||
timeout=120.0,
|
|
||||||
log_prefix=log_prefix,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def upload_video(self, file_path: str) -> bool:
|
|
||||||
"""영상 파일을 Azure Blob Storage에 업로드합니다.
|
|
||||||
|
|
||||||
URL 경로: {task_id}/video/{파일명}
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: 업로드할 파일 경로
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 업로드 성공 여부
|
|
||||||
|
|
||||||
Example:
|
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
success = await uploader.upload_video(file_path="my_video.mp4")
|
|
||||||
print(uploader.public_url)
|
|
||||||
"""
|
|
||||||
return await self._upload_file(
|
|
||||||
file_path=file_path,
|
|
||||||
category="video",
|
|
||||||
content_type="video/mp4",
|
|
||||||
timeout=180.0,
|
|
||||||
log_prefix="upload_video",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def upload_video_bytes(
|
|
||||||
self, file_content: bytes, file_name: str
|
|
||||||
) -> bool:
|
|
||||||
"""영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
|
||||||
|
|
||||||
URL 경로: {task_id}/video/{파일명}
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_content: 업로드할 파일 바이트 데이터
|
|
||||||
file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 업로드 성공 여부
|
|
||||||
|
|
||||||
Example:
|
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
|
||||||
print(uploader.public_url)
|
|
||||||
"""
|
|
||||||
# 확장자가 없으면 .mp4 추가
|
|
||||||
if not Path(file_name).suffix:
|
|
||||||
file_name = f"{file_name}.mp4"
|
|
||||||
|
|
||||||
upload_url = self._build_upload_url("video", file_name)
|
|
||||||
self._last_public_url = self._build_public_url("video", file_name)
|
|
||||||
log_prefix = "upload_video_bytes"
|
|
||||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
|
||||||
|
|
||||||
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
|
|
||||||
|
|
||||||
return await self._upload_bytes(
|
|
||||||
file_content=file_content,
|
|
||||||
upload_url=upload_url,
|
|
||||||
headers=headers,
|
|
||||||
timeout=180.0,
|
|
||||||
log_prefix=log_prefix,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def upload_image(self, file_path: str) -> bool:
|
|
||||||
"""이미지 파일을 Azure Blob Storage에 업로드합니다.
|
|
||||||
|
|
||||||
URL 경로: {task_id}/image/{파일명}
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: 업로드할 파일 경로
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 업로드 성공 여부
|
|
||||||
|
|
||||||
Example:
|
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
success = await uploader.upload_image(file_path="my_image.png")
|
|
||||||
print(uploader.public_url)
|
|
||||||
"""
|
|
||||||
extension = Path(file_path).suffix.lower()
|
|
||||||
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
|
||||||
|
|
||||||
return await self._upload_file(
|
|
||||||
file_path=file_path,
|
|
||||||
category="image",
|
|
||||||
content_type=content_type,
|
|
||||||
timeout=60.0,
|
|
||||||
log_prefix="upload_image",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def upload_image_bytes(
|
|
||||||
self, file_content: bytes, file_name: str
|
|
||||||
) -> bool:
|
|
||||||
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
|
||||||
|
|
||||||
URL 경로: {task_id}/image/{파일명}
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_content: 업로드할 파일 바이트 데이터
|
|
||||||
file_name: 저장할 파일명
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 업로드 성공 여부
|
|
||||||
|
|
||||||
Example:
|
|
||||||
uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
with open("my_image.png", "rb") as f:
|
|
||||||
content = f.read()
|
|
||||||
success = await uploader.upload_image_bytes(content, "my_image.png")
|
|
||||||
print(uploader.public_url)
|
|
||||||
"""
|
|
||||||
extension = Path(file_name).suffix.lower()
|
|
||||||
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
|
||||||
|
|
||||||
upload_url = self._build_upload_url("image", file_name)
|
|
||||||
self._last_public_url = self._build_public_url("image", file_name)
|
|
||||||
log_prefix = "upload_image_bytes"
|
|
||||||
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
|
||||||
|
|
||||||
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
|
||||||
|
|
||||||
return await self._upload_bytes(
|
|
||||||
file_content=file_content,
|
|
||||||
upload_url=upload_url,
|
|
||||||
headers=headers,
|
|
||||||
timeout=60.0,
|
|
||||||
log_prefix=log_prefix,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 사용 예시:
|
upload_video_to_azure_blob()
|
||||||
# import asyncio
|
|
||||||
#
|
upload_image_to_azure_blob()
|
||||||
# async def main():
|
|
||||||
# uploader = AzureBlobUploader(task_id="task-123")
|
|
||||||
#
|
|
||||||
# # 음악 업로드 -> {BASE_URL}/task-123/song/my_song.mp3
|
|
||||||
# await uploader.upload_music("my_song.mp3")
|
|
||||||
# print(uploader.public_url)
|
|
||||||
#
|
|
||||||
# # 영상 업로드 -> {BASE_URL}/task-123/video/my_video.mp4
|
|
||||||
# await uploader.upload_video("my_video.mp4")
|
|
||||||
# print(uploader.public_url)
|
|
||||||
#
|
|
||||||
# # 이미지 업로드 -> {BASE_URL}/task-123/image/my_image.png
|
|
||||||
# await uploader.upload_image("my_image.png")
|
|
||||||
# print(uploader.public_url)
|
|
||||||
#
|
|
||||||
# asyncio.run(main())
|
|
||||||
|
|
@ -1,746 +1,108 @@
|
||||||
"""
|
"""
|
||||||
Video API Router
|
Video API Endpoints (Test)
|
||||||
|
|
||||||
이 모듈은 Creatomate API를 통한 영상 생성 관련 API 엔드포인트를 정의합니다.
|
프론트엔드 개발을 위한 테스트용 엔드포인트입니다.
|
||||||
|
|
||||||
엔드포인트 목록:
|
|
||||||
- POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결)
|
|
||||||
- GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회
|
|
||||||
- GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling)
|
|
||||||
- GET /videos/: 완료된 영상 목록 조회 (페이지네이션)
|
|
||||||
|
|
||||||
사용 예시:
|
|
||||||
from app.video.api.routers.v1.video import router
|
|
||||||
app.include_router(router, prefix="/api/v1")
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Literal
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.dependencies.pagination import (
|
|
||||||
PaginationParams,
|
|
||||||
get_pagination_params,
|
|
||||||
)
|
|
||||||
from app.home.models import Image, Project
|
|
||||||
from app.lyric.models import Lyric
|
|
||||||
from app.song.models import Song
|
|
||||||
from app.video.models import Video
|
|
||||||
from app.video.schemas.video_schema import (
|
|
||||||
DownloadVideoResponse,
|
|
||||||
GenerateVideoResponse,
|
|
||||||
PollingVideoResponse,
|
|
||||||
VideoListItem,
|
|
||||||
VideoRenderData,
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
router = APIRouter(prefix="/video", tags=["video"])
|
router = APIRouter(prefix="/video", tags=["video"])
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
@router.get(
|
|
||||||
|
class VideoGenerateResponse(BaseModel):
|
||||||
|
"""영상 생성 응답 스키마"""
|
||||||
|
|
||||||
|
success: bool = Field(..., description="성공 여부")
|
||||||
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
|
message: str = Field(..., description="응답 메시지")
|
||||||
|
error_message: Optional[str] = Field(None, description="에러 메시지")
|
||||||
|
|
||||||
|
|
||||||
|
class VideoStatusResponse(BaseModel):
|
||||||
|
"""영상 상태 조회 응답 스키마"""
|
||||||
|
|
||||||
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
|
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||||
|
video_url: Optional[str] = Field(None, description="영상 URL")
|
||||||
|
|
||||||
|
|
||||||
|
class VideoItem(BaseModel):
|
||||||
|
"""영상 아이템 스키마"""
|
||||||
|
|
||||||
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
|
video_url: str = Field(..., description="영상 URL")
|
||||||
|
created_at: datetime = Field(..., description="생성 일시")
|
||||||
|
|
||||||
|
|
||||||
|
class VideoListResponse(BaseModel):
|
||||||
|
"""영상 목록 응답 스키마"""
|
||||||
|
|
||||||
|
videos: list[VideoItem] = Field(..., description="영상 목록")
|
||||||
|
total: int = Field(..., description="전체 개수")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
TEST_VIDEO_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/1a584e86-6a74-417d-8cff-270ef60c8646.mp4"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
"/generate/{task_id}",
|
"/generate/{task_id}",
|
||||||
summary="영상 생성 요청",
|
summary="영상 생성 요청 (테스트)",
|
||||||
description="""
|
response_model=VideoGenerateResponse,
|
||||||
Creatomate API를 통해 영상 생성을 요청합니다.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **task_id**: Project/Lyric/Song/Image의 task_id (필수) - 연관된 프로젝트, 가사, 노래, 이미지를 조회하는 데 사용
|
|
||||||
|
|
||||||
## 쿼리 파라미터
|
|
||||||
- **orientation**: 영상 방향 (horizontal: 가로형, vertical: 세로형, 기본값: vertical) - 선택
|
|
||||||
|
|
||||||
## 자동 조회 정보
|
|
||||||
- **image_urls**: Image 테이블에서 task_id로 조회 (img_order 순서로 정렬)
|
|
||||||
- **music_url**: Song 테이블의 song_result_url 사용
|
|
||||||
- **duration**: Song 테이블의 duration 사용
|
|
||||||
- **lyrics**: Song 테이블의 song_prompt (가사) 사용
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **success**: 요청 성공 여부
|
|
||||||
- **task_id**: 내부 작업 ID (Project task_id)
|
|
||||||
- **creatomate_render_id**: Creatomate 렌더 ID (상태 조회에 사용)
|
|
||||||
- **message**: 응답 메시지
|
|
||||||
|
|
||||||
## 사용 예시
|
|
||||||
```
|
|
||||||
GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431
|
|
||||||
GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 이미지는 task_id로 Image 테이블에서 자동 조회됩니다 (img_order 순서).
|
|
||||||
- 배경 음악(music_url), 영상 길이(duration), 가사(lyrics)는 task_id로 Song 테이블을 조회하여 자동으로 가져옵니다.
|
|
||||||
- 같은 task_id로 여러 Song이 있을 경우 **가장 최근 생성된 노래**를 사용합니다.
|
|
||||||
- Song의 song_result_url과 song_prompt가 있어야 영상 생성이 가능합니다.
|
|
||||||
- creatomate_render_id를 사용하여 /status/{creatomate_render_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
|
||||||
- Video 테이블에 데이터가 저장되며, project_id, lyric_id, song_id가 자동으로 연결됩니다.
|
|
||||||
""",
|
|
||||||
response_model=GenerateVideoResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "영상 생성 요청 성공"},
|
|
||||||
400: {"description": "Song의 음악 URL, 가사(song_prompt) 또는 이미지가 없음"},
|
|
||||||
404: {"description": "Project, Lyric, Song 또는 Image를 찾을 수 없음"},
|
|
||||||
500: {"description": "영상 생성 요청 실패"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
async def generate_video(
|
async def generate_video(task_id: str) -> VideoGenerateResponse:
|
||||||
task_id: str,
|
"""영상 생성 요청 테스트 엔드포인트"""
|
||||||
orientation: Literal["horizontal", "vertical"] = Query(
|
return VideoGenerateResponse(
|
||||||
default="vertical",
|
|
||||||
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
|
||||||
),
|
|
||||||
) -> GenerateVideoResponse:
|
|
||||||
"""Creatomate API를 통해 영상을 생성합니다.
|
|
||||||
|
|
||||||
1. task_id로 Project, Lyric, Song, Image 순차 조회
|
|
||||||
2. Video 테이블에 초기 데이터 저장 (status: processing)
|
|
||||||
3. Creatomate API 호출 (orientation에 따른 템플릿 자동 선택)
|
|
||||||
4. creatomate_render_id 업데이트 후 응답 반환
|
|
||||||
|
|
||||||
Note: 이 함수는 Depends(get_session)을 사용하지 않고 명시적으로 세션을 관리합니다.
|
|
||||||
외부 API 호출 중 DB 커넥션이 유지되지 않도록 하여 커넥션 타임아웃 문제를 방지합니다.
|
|
||||||
|
|
||||||
중요: SQLAlchemy AsyncSession은 단일 세션에서 동시에 여러 쿼리를 실행하는 것을
|
|
||||||
지원하지 않습니다. asyncio.gather()로 병렬 쿼리를 실행하면 세션 상태 충돌이 발생합니다.
|
|
||||||
따라서 쿼리는 순차적으로 실행합니다.
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
from app.database.session import AsyncSessionLocal
|
|
||||||
|
|
||||||
request_start = time.perf_counter()
|
|
||||||
print(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}")
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음)
|
|
||||||
# ==========================================================================
|
|
||||||
# 외부 API 호출 전에 필요한 데이터를 저장할 변수들
|
|
||||||
project_id: int | None = None
|
|
||||||
lyric_id: int | None = None
|
|
||||||
song_id: int | None = None
|
|
||||||
video_id: int | None = None
|
|
||||||
music_url: str | None = None
|
|
||||||
song_duration: float | None = None
|
|
||||||
lyrics: str | None = None
|
|
||||||
image_urls: list[str] = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
# ===== 순차 쿼리 실행: Project, Lyric, Song, Image =====
|
|
||||||
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
|
|
||||||
|
|
||||||
# Project 조회
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project)
|
|
||||||
.where(Project.task_id == task_id)
|
|
||||||
.order_by(Project.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Lyric 조회
|
|
||||||
lyric_result = await session.execute(
|
|
||||||
select(Lyric)
|
|
||||||
.where(Lyric.task_id == task_id)
|
|
||||||
.order_by(Lyric.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Song 조회
|
|
||||||
song_result = await session.execute(
|
|
||||||
select(Song)
|
|
||||||
.where(Song.task_id == task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Image 조회
|
|
||||||
image_result = await session.execute(
|
|
||||||
select(Image)
|
|
||||||
.where(Image.task_id == task_id)
|
|
||||||
.order_by(Image.img_order.asc())
|
|
||||||
)
|
|
||||||
|
|
||||||
query_time = time.perf_counter()
|
|
||||||
print(f"[generate_video] Queries completed - task_id: {task_id}, "
|
|
||||||
f"elapsed: {(query_time - request_start)*1000:.1f}ms")
|
|
||||||
|
|
||||||
# ===== 결과 처리: Project =====
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
if not project:
|
|
||||||
print(f"[generate_video] Project NOT FOUND - task_id: {task_id}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
project_id = project.id
|
|
||||||
|
|
||||||
# ===== 결과 처리: Lyric =====
|
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
|
||||||
if not lyric:
|
|
||||||
print(f"[generate_video] Lyric NOT FOUND - task_id: {task_id}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
lyric_id = lyric.id
|
|
||||||
|
|
||||||
# ===== 결과 처리: Song =====
|
|
||||||
song = song_result.scalar_one_or_none()
|
|
||||||
if not song:
|
|
||||||
print(f"[generate_video] Song NOT FOUND - task_id: {task_id}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
song_id = song.id
|
|
||||||
music_url = song.song_result_url
|
|
||||||
song_duration = song.duration
|
|
||||||
lyrics = song.song_prompt
|
|
||||||
|
|
||||||
if not music_url:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Song(id={song_id})의 음악 URL이 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not lyrics:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Song(id={song_id})의 가사(song_prompt)가 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ===== 결과 처리: Image =====
|
|
||||||
images = image_result.scalars().all()
|
|
||||||
if not images:
|
|
||||||
print(f"[generate_video] Image NOT FOUND - task_id: {task_id}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 이미지를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
image_urls = [img.img_url for img in images]
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"[generate_video] Data loaded - task_id: {task_id}, "
|
|
||||||
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
|
||||||
f"song_id: {song_id}, images: {len(image_urls)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ===== Video 테이블에 초기 데이터 저장 및 커밋 =====
|
|
||||||
video = Video(
|
|
||||||
project_id=project_id,
|
|
||||||
lyric_id=lyric_id,
|
|
||||||
song_id=song_id,
|
|
||||||
task_id=task_id,
|
|
||||||
creatomate_render_id=None,
|
|
||||||
status="processing",
|
|
||||||
)
|
|
||||||
session.add(video)
|
|
||||||
await session.commit()
|
|
||||||
video_id = video.id
|
|
||||||
stage1_time = time.perf_counter()
|
|
||||||
print(f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, "
|
|
||||||
f"stage1_elapsed: {(stage1_time - request_start)*1000:.1f}ms")
|
|
||||||
# 세션이 여기서 자동으로 닫힘 (async with 블록 종료)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
return GenerateVideoResponse(
|
|
||||||
success=False,
|
|
||||||
task_id=task_id,
|
|
||||||
creatomate_render_id=None,
|
|
||||||
message="영상 생성 요청에 실패했습니다.",
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음)
|
|
||||||
# ==========================================================================
|
|
||||||
stage2_start = time.perf_counter()
|
|
||||||
try:
|
|
||||||
print(f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}")
|
|
||||||
creatomate_service = CreatomateService(
|
|
||||||
orientation=orientation,
|
|
||||||
target_duration=song_duration,
|
|
||||||
)
|
|
||||||
print(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})")
|
|
||||||
|
|
||||||
# 6-1. 템플릿 조회 (비동기)
|
|
||||||
template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id)
|
|
||||||
print(f"[generate_video] Template fetched - task_id: {task_id}")
|
|
||||||
|
|
||||||
# 6-2. elements에서 리소스 매핑 생성
|
|
||||||
modifications = creatomate_service.elements_connect_resource_blackbox(
|
|
||||||
elements=template["source"]["elements"],
|
|
||||||
image_url_list=image_urls,
|
|
||||||
lyric=lyrics,
|
|
||||||
music_url=music_url,
|
|
||||||
)
|
|
||||||
print(f"[generate_video] Modifications created - task_id: {task_id}")
|
|
||||||
|
|
||||||
# 6-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}")
|
|
||||||
|
|
||||||
# 6-4. 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}")
|
|
||||||
|
|
||||||
# 6-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}")
|
|
||||||
|
|
||||||
# 렌더 ID 추출
|
|
||||||
if isinstance(render_response, list) and len(render_response) > 0:
|
|
||||||
creatomate_render_id = render_response[0].get("id")
|
|
||||||
elif isinstance(render_response, dict):
|
|
||||||
creatomate_render_id = render_response.get("id")
|
|
||||||
else:
|
|
||||||
creatomate_render_id = None
|
|
||||||
|
|
||||||
stage2_time = time.perf_counter()
|
|
||||||
print(
|
|
||||||
f"[generate_video] Stage 2 DONE - task_id: {task_id}, "
|
|
||||||
f"render_id: {creatomate_render_id}, "
|
|
||||||
f"stage2_elapsed: {(stage2_time - stage2_start)*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
# 외부 API 실패 시 Video 상태를 failed로 업데이트
|
|
||||||
from app.database.session import AsyncSessionLocal
|
|
||||||
async with AsyncSessionLocal() as update_session:
|
|
||||||
video_result = await update_session.execute(
|
|
||||||
select(Video).where(Video.id == video_id)
|
|
||||||
)
|
|
||||||
video_to_update = video_result.scalar_one_or_none()
|
|
||||||
if video_to_update:
|
|
||||||
video_to_update.status = "failed"
|
|
||||||
await update_session.commit()
|
|
||||||
return GenerateVideoResponse(
|
|
||||||
success=False,
|
|
||||||
task_id=task_id,
|
|
||||||
creatomate_render_id=None,
|
|
||||||
message="영상 생성 요청에 실패했습니다.",
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 3단계: creatomate_render_id 업데이트 (새 세션으로 빠르게 처리)
|
|
||||||
# ==========================================================================
|
|
||||||
stage3_start = time.perf_counter()
|
|
||||||
print(f"[generate_video] Stage 3 START - DB update - task_id: {task_id}")
|
|
||||||
try:
|
|
||||||
from app.database.session import AsyncSessionLocal
|
|
||||||
async with AsyncSessionLocal() as update_session:
|
|
||||||
video_result = await update_session.execute(
|
|
||||||
select(Video).where(Video.id == video_id)
|
|
||||||
)
|
|
||||||
video_to_update = video_result.scalar_one_or_none()
|
|
||||||
if video_to_update:
|
|
||||||
video_to_update.creatomate_render_id = creatomate_render_id
|
|
||||||
await update_session.commit()
|
|
||||||
|
|
||||||
stage3_time = time.perf_counter()
|
|
||||||
total_time = stage3_time - request_start
|
|
||||||
print(
|
|
||||||
f"[generate_video] Stage 3 DONE - task_id: {task_id}, "
|
|
||||||
f"stage3_elapsed: {(stage3_time - stage3_start)*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
f"[generate_video] SUCCESS - task_id: {task_id}, "
|
|
||||||
f"render_id: {creatomate_render_id}, "
|
|
||||||
f"total_time: {total_time*1000:.1f}ms"
|
|
||||||
)
|
|
||||||
|
|
||||||
return GenerateVideoResponse(
|
|
||||||
success=True,
|
success=True,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
creatomate_render_id=creatomate_render_id,
|
message="영상 생성 요청 성공",
|
||||||
message="영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
|
|
||||||
error_message=None,
|
error_message=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
return GenerateVideoResponse(
|
|
||||||
success=False,
|
|
||||||
task_id=task_id,
|
|
||||||
creatomate_render_id=creatomate_render_id,
|
|
||||||
message="영상 생성은 요청되었으나 DB 업데이트에 실패했습니다.",
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/status/{creatomate_render_id}",
|
"/status/{task_id}",
|
||||||
summary="영상 생성 상태 조회",
|
summary="영상 상태 조회 (테스트)",
|
||||||
description="""
|
response_model=VideoStatusResponse,
|
||||||
Creatomate API를 통해 영상 생성 작업의 상태를 조회합니다.
|
|
||||||
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 Video 테이블을 업데이트합니다.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **creatomate_render_id**: 영상 생성 시 반환된 Creatomate 렌더 ID (필수)
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **success**: 조회 성공 여부
|
|
||||||
- **status**: 작업 상태 (planned, waiting, rendering, succeeded, failed)
|
|
||||||
- **message**: 상태 메시지
|
|
||||||
- **render_data**: 렌더링 결과 데이터 (완료 시)
|
|
||||||
- **raw_response**: Creatomate API 원본 응답
|
|
||||||
|
|
||||||
## 사용 예시
|
|
||||||
```
|
|
||||||
GET /video/status/render-id-123...
|
|
||||||
```
|
|
||||||
|
|
||||||
## 상태 값
|
|
||||||
- **planned**: 예약됨
|
|
||||||
- **waiting**: 대기 중
|
|
||||||
- **transcribing**: 트랜스크립션 중
|
|
||||||
- **rendering**: 렌더링 중
|
|
||||||
- **succeeded**: 성공
|
|
||||||
- **failed**: 실패
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- succeeded 시 백그라운드에서 MP4 다운로드 및 DB 업데이트 진행
|
|
||||||
""",
|
|
||||||
response_model=PollingVideoResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "상태 조회 성공"},
|
|
||||||
500: {"description": "상태 조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
async def get_video_status(
|
async def get_video_status(task_id: str) -> VideoStatusResponse:
|
||||||
creatomate_render_id: str,
|
"""영상 상태 조회 테스트 엔드포인트"""
|
||||||
background_tasks: BackgroundTasks,
|
return VideoStatusResponse(
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> PollingVideoResponse:
|
|
||||||
"""creatomate_render_id로 영상 생성 작업의 상태를 조회합니다.
|
|
||||||
|
|
||||||
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고
|
|
||||||
Video 테이블의 status를 completed로, result_movie_url을 업데이트합니다.
|
|
||||||
"""
|
|
||||||
print(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}")
|
|
||||||
try:
|
|
||||||
creatomate_service = CreatomateService()
|
|
||||||
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")
|
|
||||||
video_url = result.get("url")
|
|
||||||
|
|
||||||
# 상태별 메시지 설정
|
|
||||||
status_messages = {
|
|
||||||
"planned": "영상 생성이 예약되었습니다.",
|
|
||||||
"waiting": "영상 생성 대기 중입니다.",
|
|
||||||
"transcribing": "트랜스크립션 진행 중입니다.",
|
|
||||||
"rendering": "영상을 렌더링하고 있습니다.",
|
|
||||||
"succeeded": "영상 생성이 완료되었습니다.",
|
|
||||||
"failed": "영상 생성에 실패했습니다.",
|
|
||||||
}
|
|
||||||
message = status_messages.get(status, f"상태: {status}")
|
|
||||||
|
|
||||||
# succeeded 상태인 경우 백그라운드 태스크 실행
|
|
||||||
if status == "succeeded" and video_url:
|
|
||||||
# creatomate_render_id로 Video 조회하여 task_id 가져오기
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video)
|
|
||||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
|
||||||
.order_by(Video.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
video = video_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if video and video.status != "completed":
|
|
||||||
# 이미 완료된 경우 백그라운드 작업 중복 실행 방지
|
|
||||||
# task_id로 Project 조회하여 store_name 가져오기
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project).where(Project.id == video.project_id)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
store_name = project.store_name if project else "video"
|
|
||||||
|
|
||||||
# 백그라운드 태스크로 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_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"),
|
|
||||||
status=status,
|
|
||||||
url=video_url,
|
|
||||||
snapshot_url=result.get("snapshot_url"),
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
|
||||||
return PollingVideoResponse(
|
|
||||||
success=True,
|
|
||||||
status=status,
|
|
||||||
message=message,
|
|
||||||
render_data=render_data,
|
|
||||||
raw_response=result,
|
|
||||||
error_message=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
print(f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
|
||||||
return PollingVideoResponse(
|
|
||||||
success=False,
|
|
||||||
status="error",
|
|
||||||
message="상태 조회에 실패했습니다.",
|
|
||||||
render_data=None,
|
|
||||||
raw_response=None,
|
|
||||||
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/download/{task_id}",
|
|
||||||
summary="영상 생성 URL 조회",
|
|
||||||
description="""
|
|
||||||
task_id를 기반으로 Video 테이블의 상태를 polling하고,
|
|
||||||
completed인 경우 Project 정보와 영상 URL을 반환합니다.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **task_id**: 프로젝트 task_id (필수)
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **success**: 조회 성공 여부
|
|
||||||
- **status**: 처리 상태 (processing, completed, failed)
|
|
||||||
- **message**: 응답 메시지
|
|
||||||
- **store_name**: 업체명
|
|
||||||
- **region**: 지역명
|
|
||||||
- **task_id**: 작업 고유 식별자
|
|
||||||
- **result_movie_url**: 영상 결과 URL (completed 시)
|
|
||||||
- **created_at**: 생성 일시
|
|
||||||
|
|
||||||
## 사용 예시
|
|
||||||
```
|
|
||||||
GET /video/download/019123ab-cdef-7890-abcd-ef1234567890
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- processing 상태인 경우 result_movie_url은 null입니다.
|
|
||||||
- completed 상태인 경우 Project 정보와 함께 result_movie_url을 반환합니다.
|
|
||||||
""",
|
|
||||||
response_model=DownloadVideoResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "조회 성공"},
|
|
||||||
404: {"description": "Video를 찾을 수 없음"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def download_video(
|
|
||||||
task_id: str,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> DownloadVideoResponse:
|
|
||||||
"""task_id로 Video 상태를 polling하고 completed 시 Project 정보와 영상 URL을 반환합니다."""
|
|
||||||
print(f"[download_video] START - task_id: {task_id}")
|
|
||||||
try:
|
|
||||||
# task_id로 Video 조회 (여러 개 있을 경우 가장 최근 것 선택)
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video)
|
|
||||||
.where(Video.task_id == task_id)
|
|
||||||
.order_by(Video.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
video = video_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not video:
|
|
||||||
print(f"[download_video] Video NOT FOUND - task_id: {task_id}")
|
|
||||||
return DownloadVideoResponse(
|
|
||||||
success=False,
|
|
||||||
status="not_found",
|
|
||||||
message=f"task_id '{task_id}'에 해당하는 Video를 찾을 수 없습니다.",
|
|
||||||
error_message="Video not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"[download_video] Video found - task_id: {task_id}, status: {video.status}")
|
|
||||||
|
|
||||||
# processing 상태인 경우
|
|
||||||
if video.status == "processing":
|
|
||||||
print(f"[download_video] PROCESSING - task_id: {task_id}")
|
|
||||||
return DownloadVideoResponse(
|
|
||||||
success=True,
|
|
||||||
status="processing",
|
|
||||||
message="영상 생성이 진행 중입니다.",
|
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
)
|
|
||||||
|
|
||||||
# failed 상태인 경우
|
|
||||||
if video.status == "failed":
|
|
||||||
print(f"[download_video] FAILED - task_id: {task_id}")
|
|
||||||
return DownloadVideoResponse(
|
|
||||||
success=False,
|
|
||||||
status="failed",
|
|
||||||
message="영상 생성에 실패했습니다.",
|
|
||||||
task_id=task_id,
|
|
||||||
error_message="Video generation failed",
|
|
||||||
)
|
|
||||||
|
|
||||||
# completed 상태인 경우 - Project 정보 조회
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project).where(Project.id == video.project_id)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
print(f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}")
|
|
||||||
return DownloadVideoResponse(
|
|
||||||
success=True,
|
|
||||||
status="completed",
|
status="completed",
|
||||||
message="영상 다운로드가 완료되었습니다.",
|
video_url=TEST_VIDEO_URL,
|
||||||
store_name=project.store_name if project else None,
|
|
||||||
region=project.region if project else None,
|
|
||||||
task_id=task_id,
|
|
||||||
result_movie_url=video.result_movie_url,
|
|
||||||
created_at=video.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[download_video] EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
return DownloadVideoResponse(
|
|
||||||
success=False,
|
|
||||||
status="error",
|
|
||||||
message="영상 다운로드 조회에 실패했습니다.",
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"s/",
|
"s/",
|
||||||
summary="생성된 영상 목록 조회",
|
summary="영상 목록 조회 (테스트)",
|
||||||
description="""
|
response_model=VideoListResponse,
|
||||||
완료된 영상 목록을 페이지네이션하여 조회합니다.
|
|
||||||
|
|
||||||
## 쿼리 파라미터
|
|
||||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
|
||||||
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at)
|
|
||||||
- **total**: 전체 데이터 수
|
|
||||||
- **page**: 현재 페이지
|
|
||||||
- **page_size**: 페이지당 데이터 수
|
|
||||||
- **total_pages**: 전체 페이지 수
|
|
||||||
- **has_next**: 다음 페이지 존재 여부
|
|
||||||
- **has_prev**: 이전 페이지 존재 여부
|
|
||||||
|
|
||||||
## 사용 예시
|
|
||||||
```
|
|
||||||
GET /videos/?page=1&page_size=10
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- status가 'completed'인 영상만 반환됩니다.
|
|
||||||
- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다.
|
|
||||||
- created_at 기준 내림차순 정렬됩니다.
|
|
||||||
""",
|
|
||||||
response_model=PaginatedResponse[VideoListItem],
|
|
||||||
responses={
|
|
||||||
200: {"description": "영상 목록 조회 성공"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
async def get_videos(
|
async def get_videos() -> VideoListResponse:
|
||||||
session: AsyncSession = Depends(get_session),
|
"""영상 목록 조회 테스트 엔드포인트"""
|
||||||
pagination: PaginationParams = Depends(get_pagination_params),
|
now = datetime.now()
|
||||||
) -> PaginatedResponse[VideoListItem]:
|
videos = [
|
||||||
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
|
VideoItem(
|
||||||
print(f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}")
|
task_id=f"test-task-id-{i:03d}",
|
||||||
try:
|
video_url=TEST_VIDEO_URL,
|
||||||
offset = (pagination.page - 1) * pagination.page_size
|
created_at=now - timedelta(hours=i),
|
||||||
|
|
||||||
# 서브쿼리: task_id별 최신 Video의 id 조회 (completed 상태만)
|
|
||||||
subquery = (
|
|
||||||
select(func.max(Video.id).label("max_id"))
|
|
||||||
.where(Video.status == "completed")
|
|
||||||
.group_by(Video.task_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
)
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
|
||||||
# 전체 개수 조회 (task_id별 최신 1개만)
|
return VideoListResponse(
|
||||||
count_query = select(func.count()).select_from(subquery)
|
videos=videos,
|
||||||
total_result = await session.execute(count_query)
|
total=len(videos),
|
||||||
total = total_result.scalar() or 0
|
|
||||||
|
|
||||||
# 데이터 조회 (completed 상태, task_id별 최신 1개만, 최신순)
|
|
||||||
query = (
|
|
||||||
select(Video)
|
|
||||||
.where(Video.id.in_(select(subquery.c.max_id)))
|
|
||||||
.order_by(Video.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(pagination.page_size)
|
|
||||||
)
|
|
||||||
result = await session.execute(query)
|
|
||||||
videos = result.scalars().all()
|
|
||||||
|
|
||||||
# Project 정보 일괄 조회 (N+1 문제 해결)
|
|
||||||
project_ids = [v.project_id for v in videos if v.project_id]
|
|
||||||
projects_map: dict = {}
|
|
||||||
if project_ids:
|
|
||||||
projects_result = await session.execute(
|
|
||||||
select(Project).where(Project.id.in_(project_ids))
|
|
||||||
)
|
|
||||||
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
|
||||||
|
|
||||||
# VideoListItem으로 변환
|
|
||||||
items = []
|
|
||||||
for video in videos:
|
|
||||||
project = projects_map.get(video.project_id)
|
|
||||||
|
|
||||||
item = VideoListItem(
|
|
||||||
store_name=project.store_name if project else None,
|
|
||||||
region=project.region if project else None,
|
|
||||||
task_id=video.task_id,
|
|
||||||
result_movie_url=video.result_movie_url,
|
|
||||||
created_at=video.created_at,
|
|
||||||
)
|
|
||||||
items.append(item)
|
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=pagination.page,
|
|
||||||
page_size=pagination.page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, "
|
|
||||||
f"page_size: {pagination.page_size}, items_count: {len(items)}"
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[get_videos] EXCEPTION - error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,6 @@ class Video(Base):
|
||||||
comment="영상 생성 작업 고유 식별자 (UUID)",
|
comment="영상 생성 작업 고유 식별자 (UUID)",
|
||||||
)
|
)
|
||||||
|
|
||||||
creatomate_render_id: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(64),
|
|
||||||
nullable=True,
|
|
||||||
comment="Creatomate API 렌더 ID",
|
|
||||||
)
|
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
|
||||||
|
|
@ -1,156 +1,91 @@
|
||||||
"""
|
from dataclasses import dataclass, field
|
||||||
Video API Schemas
|
|
||||||
|
|
||||||
영상 생성 관련 Pydantic 스키마를 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Literal, Optional
|
from typing import Dict, List
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from fastapi import Request
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
@dataclass
|
||||||
# Response Schemas
|
class StoreData:
|
||||||
# =============================================================================
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
store_name: str
|
||||||
|
store_category: str | None = None
|
||||||
|
store_region: str | None = None
|
||||||
|
store_address: str | None = None
|
||||||
|
store_phone_number: str | None = None
|
||||||
|
store_info: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class GenerateVideoResponse(BaseModel):
|
@dataclass
|
||||||
"""영상 생성 응답 스키마
|
class AttributeData:
|
||||||
|
id: int
|
||||||
|
attr_category: str
|
||||||
|
attr_value: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /video/generate/{task_id}
|
|
||||||
Returns the task IDs for tracking video generation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
@dataclass
|
||||||
json_schema_extra={
|
class SongSampleData:
|
||||||
"example": {
|
id: int
|
||||||
"success": True,
|
ai: str
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
ai_model: str
|
||||||
"creatomate_render_id": "render-id-123456",
|
sample_song: str
|
||||||
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
|
season: str | None = None
|
||||||
"error_message": None,
|
num_of_people: int | None = None
|
||||||
}
|
people_category: str | None = None
|
||||||
}
|
genre: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PromptTemplateData:
|
||||||
|
id: int
|
||||||
|
prompt: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SongFormData:
|
||||||
|
store_name: str
|
||||||
|
store_id: str
|
||||||
|
prompts: str
|
||||||
|
attributes: Dict[str, str] = field(default_factory=dict)
|
||||||
|
attributes_str: str = ""
|
||||||
|
lyrics_ids: List[int] = field(default_factory=list)
|
||||||
|
llm_model: str = "gpt-4o"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def from_form(cls, request: Request):
|
||||||
|
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
||||||
|
form_data = await request.form()
|
||||||
|
|
||||||
|
# 고정 필드명들
|
||||||
|
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
||||||
|
|
||||||
|
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
||||||
|
lyrics_ids = []
|
||||||
|
attributes = {}
|
||||||
|
|
||||||
|
for key, value in form_data.items():
|
||||||
|
if key.startswith("lyrics-"):
|
||||||
|
lyrics_id = key.split("-")[1]
|
||||||
|
lyrics_ids.append(int(lyrics_id))
|
||||||
|
elif key not in fixed_keys:
|
||||||
|
attributes[key] = value
|
||||||
|
|
||||||
|
# attributes를 문자열로 변환
|
||||||
|
attributes_str = (
|
||||||
|
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
||||||
|
if attributes
|
||||||
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
success: bool = Field(..., description="요청 성공 여부")
|
return cls(
|
||||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
store_name=form_data.get("store_info_name", ""),
|
||||||
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
store_id=form_data.get("store_id", ""),
|
||||||
message: str = Field(..., description="응답 메시지")
|
attributes=attributes,
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
attributes_str=attributes_str,
|
||||||
|
lyrics_ids=lyrics_ids,
|
||||||
|
llm_model=form_data.get("llm_model", "gpt-4o"),
|
||||||
class VideoRenderData(BaseModel):
|
prompts=form_data.get("prompts", ""),
|
||||||
"""Creatomate 렌더링 결과 데이터"""
|
|
||||||
|
|
||||||
id: Optional[str] = Field(None, description="렌더 ID")
|
|
||||||
status: Optional[str] = Field(None, description="렌더 상태")
|
|
||||||
url: Optional[str] = Field(None, description="영상 URL")
|
|
||||||
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
|
|
||||||
|
|
||||||
|
|
||||||
class PollingVideoResponse(BaseModel):
|
|
||||||
"""영상 생성 상태 조회 응답 스키마
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /video/status/{creatomate_render_id}
|
|
||||||
Creatomate API 작업 상태를 조회합니다.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
상태 값:
|
|
||||||
- planned: 예약됨
|
|
||||||
- waiting: 대기 중
|
|
||||||
- transcribing: 트랜스크립션 중
|
|
||||||
- rendering: 렌더링 중
|
|
||||||
- succeeded: 성공
|
|
||||||
- failed: 실패
|
|
||||||
|
|
||||||
Example Response (Success):
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"status": "succeeded",
|
|
||||||
"message": "영상 생성이 완료되었습니다.",
|
|
||||||
"render_data": {
|
|
||||||
"id": "render-id",
|
|
||||||
"status": "succeeded",
|
|
||||||
"url": "https://...",
|
|
||||||
"snapshot_url": "https://..."
|
|
||||||
},
|
|
||||||
"raw_response": {...},
|
|
||||||
"error_message": null
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
success: bool = Field(..., description="조회 성공 여부")
|
|
||||||
status: Optional[str] = Field(
|
|
||||||
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
|
|
||||||
)
|
)
|
||||||
message: str = Field(..., description="상태 메시지")
|
|
||||||
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
|
|
||||||
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
|
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadVideoResponse(BaseModel):
|
|
||||||
"""영상 다운로드 응답 스키마
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /video/download/{task_id}
|
|
||||||
Polls for video completion and returns project info with video URL.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
상태 값:
|
|
||||||
- processing: 영상 생성 진행 중 (result_movie_url은 null)
|
|
||||||
- completed: 영상 생성 완료 (result_movie_url 포함)
|
|
||||||
- failed: 영상 생성 실패
|
|
||||||
- not_found: task_id에 해당하는 Video 없음
|
|
||||||
- error: 조회 중 오류 발생
|
|
||||||
|
|
||||||
Example Response (Completed):
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"status": "completed",
|
|
||||||
"message": "영상 다운로드가 완료되었습니다.",
|
|
||||||
"store_name": "스테이 머뭄",
|
|
||||||
"region": "군산",
|
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
|
||||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
|
||||||
"created_at": "2025-01-15T12:00:00",
|
|
||||||
"error_message": null
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
success: bool = Field(..., description="다운로드 성공 여부")
|
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
|
||||||
message: str = Field(..., description="응답 메시지")
|
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
|
||||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
|
||||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
|
||||||
|
|
||||||
|
|
||||||
class VideoListItem(BaseModel):
|
|
||||||
"""영상 목록 아이템 스키마
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /videos 응답의 개별 영상 정보
|
|
||||||
|
|
||||||
Example:
|
|
||||||
{
|
|
||||||
"store_name": "스테이 머뭄",
|
|
||||||
"region": "군산",
|
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
|
||||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
|
||||||
"created_at": "2025-01-15T12:00:00"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
|
||||||
|
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
"""
|
|
||||||
Video Background Tasks
|
|
||||||
|
|
||||||
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
import httpx
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
|
||||||
from app.video.models import Video
|
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
|
||||||
|
|
||||||
|
|
||||||
async def download_and_upload_video_to_blob(
|
|
||||||
task_id: str,
|
|
||||||
video_url: str,
|
|
||||||
store_name: str,
|
|
||||||
) -> None:
|
|
||||||
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 프로젝트 task_id
|
|
||||||
video_url: 다운로드할 영상 URL
|
|
||||||
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:
|
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
|
||||||
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_to_blob] Temp directory created - path: {temp_file_path}")
|
|
||||||
|
|
||||||
# 영상 파일 다운로드
|
|
||||||
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=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_to_blob] File downloaded - task_id: {task_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_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
|
||||||
|
|
||||||
# Video 테이블 업데이트 (새 세션 사용)
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
# 여러 개 있을 경우 가장 최근 것 선택
|
|
||||||
result = await session.execute(
|
|
||||||
select(Video)
|
|
||||||
.where(Video.task_id == task_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_to_blob] SUCCESS - task_id: {task_id}, status: completed")
|
|
||||||
else:
|
|
||||||
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_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
|
||||||
# 실패 시 Video 테이블 업데이트
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Video)
|
|
||||||
.where(Video.task_id == task_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_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 BackgroundSessionLocal() 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 BackgroundSessionLocal() 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 BackgroundSessionLocal() 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 # 디렉토리가 비어있지 않으면 무시
|
|
||||||
43
config.py
43
config.py
|
|
@ -127,47 +127,6 @@ class CrawlerSettings(BaseSettings):
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
class AzureBlobSettings(BaseSettings):
|
|
||||||
"""Azure Blob Storage 설정"""
|
|
||||||
|
|
||||||
AZURE_BLOB_SAS_TOKEN: str = Field(
|
|
||||||
default="",
|
|
||||||
description="Azure Blob Storage SAS 토큰",
|
|
||||||
)
|
|
||||||
AZURE_BLOB_BASE_URL: str = Field(
|
|
||||||
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
|
|
||||||
description="Azure Blob Storage 기본 URL",
|
|
||||||
)
|
|
||||||
|
|
||||||
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()
|
||||||
|
|
@ -175,5 +134,3 @@ security_settings = SecuritySettings()
|
||||||
notification_settings = NotificationSettings()
|
notification_settings = NotificationSettings()
|
||||||
cors_settings = CORSSettings()
|
cors_settings = CORSSettings()
|
||||||
crawler_settings = CrawlerSettings()
|
crawler_settings = CrawlerSettings()
|
||||||
azure_blob_settings = AzureBlobSettings()
|
|
||||||
creatomate_settings = CreatomateSettings()
|
|
||||||
|
|
|
||||||
|
|
@ -1,783 +0,0 @@
|
||||||
# O2O-CASTAD Backend 비동기 아키텍처 및 설계 분석 보고서
|
|
||||||
|
|
||||||
> **문서 버전**: 1.0
|
|
||||||
> **작성일**: 2025-12-29
|
|
||||||
> **대상**: 개발자, 아키텍트, 코드 리뷰어
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 목차
|
|
||||||
|
|
||||||
1. [Executive Summary](#1-executive-summary)
|
|
||||||
2. [데이터베이스 세션 관리 아키텍처](#2-데이터베이스-세션-관리-아키텍처)
|
|
||||||
3. [비동기 처리 패턴](#3-비동기-처리-패턴)
|
|
||||||
4. [외부 API 통합 설계](#4-외부-api-통합-설계)
|
|
||||||
5. [백그라운드 태스크 워크플로우](#5-백그라운드-태스크-워크플로우)
|
|
||||||
6. [쿼리 최적화 전략](#6-쿼리-최적화-전략)
|
|
||||||
7. [설계 강점 분석](#7-설계-강점-분석)
|
|
||||||
8. [개선 권장 사항](#8-개선-권장-사항)
|
|
||||||
9. [아키텍처 다이어그램](#9-아키텍처-다이어그램)
|
|
||||||
10. [결론](#10-결론)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Executive Summary
|
|
||||||
|
|
||||||
### 1.1 프로젝트 개요
|
|
||||||
|
|
||||||
O2O-CASTAD Backend는 FastAPI 기반의 비동기 백엔드 서비스로, AI 기반 광고 영상 자동 생성 파이프라인을 제공합니다. 주요 외부 서비스(Creatomate, Suno, ChatGPT, Azure Blob Storage)와의 통합을 통해 가사 생성 → 노래 생성 → 영상 생성의 파이프라인을 구현합니다.
|
|
||||||
|
|
||||||
### 1.2 주요 성과
|
|
||||||
|
|
||||||
| 영역 | 개선 전 | 개선 후 | 개선율 |
|
|
||||||
|------|---------|---------|--------|
|
|
||||||
| DB 쿼리 실행 | 순차 (200ms) | 병렬 (55ms) | **72% 감소** |
|
|
||||||
| 템플릿 API 호출 | 매번 호출 (1-2s) | 캐시 HIT (0ms) | **100% 감소** |
|
|
||||||
| HTTP 클라이언트 | 매번 생성 (50ms) | 풀 재사용 (0ms) | **100% 감소** |
|
|
||||||
| 세션 타임아웃 에러 | 빈번 | 해결 | **안정성 확보** |
|
|
||||||
|
|
||||||
### 1.3 핵심 아키텍처 결정
|
|
||||||
|
|
||||||
1. **이중 커넥션 풀 아키텍처**: 요청/백그라운드 분리
|
|
||||||
2. **명시적 세션 라이프사이클**: 외부 API 호출 전 세션 해제
|
|
||||||
3. **모듈 레벨 싱글톤**: HTTP 클라이언트 및 템플릿 캐시
|
|
||||||
4. **asyncio.gather() 기반 병렬 쿼리**: 다중 테이블 동시 조회
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 데이터베이스 세션 관리 아키텍처
|
|
||||||
|
|
||||||
### 2.1 이중 엔진 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ DATABASE LAYER │
|
|
||||||
├─────────────────────────────┬───────────────────────────────┤
|
|
||||||
│ MAIN ENGINE │ BACKGROUND ENGINE │
|
|
||||||
│ (FastAPI Requests) │ (Worker Tasks) │
|
|
||||||
├─────────────────────────────┼───────────────────────────────┤
|
|
||||||
│ pool_size: 20 │ pool_size: 10 │
|
|
||||||
│ max_overflow: 20 │ max_overflow: 10 │
|
|
||||||
│ pool_timeout: 30s │ pool_timeout: 60s │
|
|
||||||
│ Total: 최대 40 연결 │ Total: 최대 20 연결 │
|
|
||||||
├─────────────────────────────┼───────────────────────────────┤
|
|
||||||
│ AsyncSessionLocal │ BackgroundSessionLocal │
|
|
||||||
│ → Router endpoints │ → download_and_upload_* │
|
|
||||||
│ → Direct API calls │ → generate_lyric_background │
|
|
||||||
└─────────────────────────────┴───────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**위치**: `app/database/session.py`
|
|
||||||
|
|
||||||
### 2.2 엔진 설정 상세
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 메인 엔진 (FastAPI 요청용)
|
|
||||||
engine = create_async_engine(
|
|
||||||
url=db_settings.MYSQL_URL,
|
|
||||||
pool_size=20, # 기본 풀 크기
|
|
||||||
max_overflow=20, # 추가 연결 허용
|
|
||||||
pool_timeout=30, # 연결 대기 최대 시간
|
|
||||||
pool_recycle=3600, # 1시간마다 연결 재생성
|
|
||||||
pool_pre_ping=True, # 연결 유효성 검사 (핵심!)
|
|
||||||
pool_reset_on_return="rollback", # 반환 시 롤백
|
|
||||||
)
|
|
||||||
|
|
||||||
# 백그라운드 엔진 (워커 태스크용)
|
|
||||||
background_engine = create_async_engine(
|
|
||||||
url=db_settings.MYSQL_URL,
|
|
||||||
pool_size=10, # 더 작은 풀
|
|
||||||
max_overflow=10,
|
|
||||||
pool_timeout=60, # 백그라운드는 대기 여유
|
|
||||||
pool_recycle=3600,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 세션 관리 패턴
|
|
||||||
|
|
||||||
#### 패턴 1: FastAPI 의존성 주입 (단순 CRUD)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@router.get("/items/{id}")
|
|
||||||
async def get_item(
|
|
||||||
id: int,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
):
|
|
||||||
result = await session.execute(select(Item).where(Item.id == id))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
```
|
|
||||||
|
|
||||||
**적용 엔드포인트:**
|
|
||||||
- `GET /videos/` - 목록 조회
|
|
||||||
- `GET /video/download/{task_id}` - 상태 조회
|
|
||||||
- `GET /songs/` - 목록 조회
|
|
||||||
|
|
||||||
#### 패턴 2: 명시적 세션 관리 (외부 API 호출 포함)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@router.get("/generate/{task_id}")
|
|
||||||
async def generate_video(task_id: str):
|
|
||||||
# 1단계: 명시적 세션 열기 → DB 작업 → 세션 닫기
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
# 병렬 쿼리 실행
|
|
||||||
results = await asyncio.gather(...)
|
|
||||||
# 초기 데이터 저장
|
|
||||||
await session.commit()
|
|
||||||
# 세션 닫힘 (async with 블록 종료)
|
|
||||||
|
|
||||||
# 2단계: 외부 API 호출 (세션 없음 - 커넥션 점유 안함)
|
|
||||||
response = await creatomate_service.make_api_call()
|
|
||||||
|
|
||||||
# 3단계: 새 세션으로 업데이트
|
|
||||||
async with AsyncSessionLocal() as update_session:
|
|
||||||
video.render_id = response["id"]
|
|
||||||
await update_session.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
**적용 엔드포인트:**
|
|
||||||
- `GET /video/generate/{task_id}` - 영상 생성
|
|
||||||
- `GET /song/generate/{task_id}` - 노래 생성
|
|
||||||
|
|
||||||
#### 패턴 3: 백그라운드 태스크 세션
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def download_and_upload_video_to_blob(task_id: str, ...):
|
|
||||||
# 백그라운드 전용 세션 팩토리 사용
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
result = await session.execute(...)
|
|
||||||
video.status = "completed"
|
|
||||||
await session.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
**적용 함수:**
|
|
||||||
- `download_and_upload_video_to_blob()`
|
|
||||||
- `download_and_upload_song_to_blob()`
|
|
||||||
- `generate_lyric_background()`
|
|
||||||
|
|
||||||
### 2.4 해결된 문제: 세션 타임아웃
|
|
||||||
|
|
||||||
**문제 상황:**
|
|
||||||
```
|
|
||||||
RuntimeError: unable to perform operation on
|
|
||||||
<TCPTransport closed=True reading=False ...>; the handler is closed
|
|
||||||
```
|
|
||||||
|
|
||||||
**원인:**
|
|
||||||
- `Depends(get_session)`으로 주입된 세션이 요청 전체 동안 유지
|
|
||||||
- 외부 API 호출 (수 초~수 분) 중 TCP 커넥션 타임아웃
|
|
||||||
- 요청 종료 시점에 이미 닫힌 커넥션 정리 시도
|
|
||||||
|
|
||||||
**해결책:**
|
|
||||||
```python
|
|
||||||
# 변경 전: 세션이 요청 전체 동안 유지
|
|
||||||
async def generate_video(session: AsyncSession = Depends(get_session)):
|
|
||||||
await session.execute(...) # DB 작업
|
|
||||||
await creatomate_api() # 외부 API (세션 유지됨 - 문제!)
|
|
||||||
await session.commit() # 타임아웃 에러 발생 가능
|
|
||||||
|
|
||||||
# 변경 후: 명시적 세션 관리
|
|
||||||
async def generate_video():
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
await session.execute(...)
|
|
||||||
await session.commit()
|
|
||||||
# 세션 닫힘
|
|
||||||
|
|
||||||
await creatomate_api() # 외부 API (세션 없음 - 안전!)
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
# 업데이트
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 비동기 처리 패턴
|
|
||||||
|
|
||||||
### 3.1 asyncio.gather() 병렬 쿼리
|
|
||||||
|
|
||||||
**위치**: `app/video/api/routers/v1/video.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 4개의 독립적인 쿼리를 병렬로 실행
|
|
||||||
project_result, lyric_result, song_result, image_result = (
|
|
||||||
await asyncio.gather(
|
|
||||||
session.execute(project_query),
|
|
||||||
session.execute(lyric_query),
|
|
||||||
session.execute(song_query),
|
|
||||||
session.execute(image_query),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**성능 비교:**
|
|
||||||
```
|
|
||||||
[순차 실행]
|
|
||||||
Query 1 ──────▶ 50ms
|
|
||||||
Query 2 ──────▶ 50ms
|
|
||||||
Query 3 ──────▶ 50ms
|
|
||||||
Query 4 ──────▶ 50ms
|
|
||||||
총 소요시간: 200ms
|
|
||||||
|
|
||||||
[병렬 실행]
|
|
||||||
Query 1 ──────▶ 50ms
|
|
||||||
Query 2 ──────▶ 50ms
|
|
||||||
Query 3 ──────▶ 50ms
|
|
||||||
Query 4 ──────▶ 50ms
|
|
||||||
총 소요시간: ~55ms (가장 느린 쿼리 + 오버헤드)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 FastAPI BackgroundTasks 활용
|
|
||||||
|
|
||||||
```python
|
|
||||||
@router.post("/generate")
|
|
||||||
async def generate_lyric(
|
|
||||||
request_body: GenerateLyricRequest,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
):
|
|
||||||
# 즉시 응답할 데이터 저장
|
|
||||||
lyric = Lyric(task_id=task_id, status="processing")
|
|
||||||
session.add(lyric)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# 백그라운드 태스크 스케줄링
|
|
||||||
background_tasks.add_task(
|
|
||||||
generate_lyric_background,
|
|
||||||
task_id=task_id,
|
|
||||||
prompt=prompt,
|
|
||||||
language=request_body.language,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 즉시 응답 반환
|
|
||||||
return GenerateLyricResponse(success=True, task_id=task_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 비동기 컨텍스트 관리자
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 앱 라이프사이클 관리
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
# Startup
|
|
||||||
await create_db_tables()
|
|
||||||
print("Database tables created")
|
|
||||||
|
|
||||||
yield # 앱 실행
|
|
||||||
|
|
||||||
# Shutdown
|
|
||||||
await dispose_engine()
|
|
||||||
print("Database engines disposed")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 외부 API 통합 설계
|
|
||||||
|
|
||||||
### 4.1 Creatomate 서비스
|
|
||||||
|
|
||||||
**위치**: `app/utils/creatomate.py`
|
|
||||||
|
|
||||||
#### HTTP 클라이언트 싱글톤
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 모듈 레벨 공유 클라이언트
|
|
||||||
_shared_client: httpx.AsyncClient | None = None
|
|
||||||
|
|
||||||
async def get_shared_client() -> httpx.AsyncClient:
|
|
||||||
global _shared_client
|
|
||||||
if _shared_client is None or _shared_client.is_closed:
|
|
||||||
_shared_client = httpx.AsyncClient(
|
|
||||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
|
||||||
limits=httpx.Limits(
|
|
||||||
max_keepalive_connections=10,
|
|
||||||
max_connections=20,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return _shared_client
|
|
||||||
```
|
|
||||||
|
|
||||||
**장점:**
|
|
||||||
- 커넥션 풀 재사용으로 TCP handshake 오버헤드 제거
|
|
||||||
- Keep-alive로 연결 유지
|
|
||||||
- 앱 종료 시 `close_shared_client()` 호출로 정리
|
|
||||||
|
|
||||||
#### 템플릿 캐싱
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 모듈 레벨 캐시
|
|
||||||
_template_cache: dict[str, dict] = {}
|
|
||||||
CACHE_TTL_SECONDS = 300 # 5분
|
|
||||||
|
|
||||||
async def get_one_template_data(self, template_id: str, use_cache: bool = True):
|
|
||||||
# 캐시 확인
|
|
||||||
if use_cache and template_id in _template_cache:
|
|
||||||
cached = _template_cache[template_id]
|
|
||||||
if _is_cache_valid(cached["cached_at"]):
|
|
||||||
print(f"[CreatomateService] Cache HIT - {template_id}")
|
|
||||||
return copy.deepcopy(cached["data"])
|
|
||||||
|
|
||||||
# API 호출 및 캐시 저장
|
|
||||||
data = await self._fetch_from_api(template_id)
|
|
||||||
_template_cache[template_id] = {
|
|
||||||
"data": data,
|
|
||||||
"cached_at": time.time(),
|
|
||||||
}
|
|
||||||
return copy.deepcopy(data)
|
|
||||||
```
|
|
||||||
|
|
||||||
**캐싱 전략:**
|
|
||||||
- 첫 번째 요청: API 호출 (1-2초)
|
|
||||||
- 이후 요청 (5분 내): 캐시 반환 (~0ms)
|
|
||||||
- TTL 만료 후: 자동 갱신
|
|
||||||
|
|
||||||
### 4.2 Suno 서비스
|
|
||||||
|
|
||||||
**위치**: `app/utils/suno.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
class SunoService:
|
|
||||||
async def generate_music(self, prompt: str, callback_url: str = None):
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{self.base_url}/generate/music",
|
|
||||||
json={
|
|
||||||
"prompt": prompt,
|
|
||||||
"callback_url": callback_url,
|
|
||||||
},
|
|
||||||
timeout=60.0,
|
|
||||||
)
|
|
||||||
return response.json()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 ChatGPT 서비스
|
|
||||||
|
|
||||||
**위치**: `app/utils/chatgpt_prompt.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ChatgptService:
|
|
||||||
async def generate(self, prompt: str) -> str:
|
|
||||||
# OpenAI API 호출
|
|
||||||
response = await self.client.chat.completions.create(
|
|
||||||
model="gpt-4",
|
|
||||||
messages=[{"role": "user", "content": prompt}],
|
|
||||||
)
|
|
||||||
return response.choices[0].message.content
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 Azure Blob Storage
|
|
||||||
|
|
||||||
**위치**: `app/utils/upload_blob_as_request.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AzureBlobUploader:
|
|
||||||
async def upload_video(self, file_path: str) -> bool:
|
|
||||||
# 비동기 업로드
|
|
||||||
async with aiofiles.open(file_path, "rb") as f:
|
|
||||||
content = await f.read()
|
|
||||||
# Blob 업로드 로직
|
|
||||||
return True
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 백그라운드 태스크 워크플로우
|
|
||||||
|
|
||||||
### 5.1 3단계 워크플로우 패턴
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ REQUEST PHASE │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ API Request │───▶│ Save Initial │───▶│ Return Response │ │
|
|
||||||
│ │ │ │ Record │ │ (task_id) │ │
|
|
||||||
│ └──────────────┘ │ status= │ └──────────────────┘ │
|
|
||||||
│ │ "processing" │ │
|
|
||||||
│ └──────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ POLLING PHASE │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ Client Polls │───▶│ Query │───▶│ Return Status │ │
|
|
||||||
│ │ /status/id │ │ External API │ │ + Trigger BG │ │
|
|
||||||
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
│ (if status == "succeeded")
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ BACKGROUND COMPLETION PHASE │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ Download │───▶│ Upload to │───▶│ Update DB │ │
|
|
||||||
│ │ Result File │ │ Azure Blob │ │ status=completed │ │
|
|
||||||
│ └──────────────┘ └──────────────┘ │ result_url=... │ │
|
|
||||||
│ └──────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 영상 생성 플로우
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 1단계: 즉시 응답
|
|
||||||
@router.get("/video/generate/{task_id}")
|
|
||||||
async def generate_video(task_id: str):
|
|
||||||
# DB에서 필요한 데이터 조회 (병렬)
|
|
||||||
# Video 레코드 생성 (status="processing")
|
|
||||||
# Creatomate API 호출 → render_id 획득
|
|
||||||
return {"success": True, "render_id": render_id}
|
|
||||||
|
|
||||||
# 2단계: 폴링
|
|
||||||
@router.get("/video/status/{render_id}")
|
|
||||||
async def get_video_status(render_id: str, background_tasks: BackgroundTasks):
|
|
||||||
status = await creatomate_service.get_render_status(render_id)
|
|
||||||
|
|
||||||
if status == "succeeded":
|
|
||||||
# 백그라운드 태스크 트리거
|
|
||||||
background_tasks.add_task(
|
|
||||||
download_and_upload_video_to_blob,
|
|
||||||
task_id=video.task_id,
|
|
||||||
video_url=status["url"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": status}
|
|
||||||
|
|
||||||
# 3단계: 백그라운드 완료
|
|
||||||
async def download_and_upload_video_to_blob(task_id: str, video_url: str):
|
|
||||||
# 임시 파일 다운로드
|
|
||||||
# Azure Blob 업로드
|
|
||||||
# DB 업데이트 (BackgroundSessionLocal 사용)
|
|
||||||
# 임시 파일 삭제
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 에러 처리 전략
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def download_and_upload_video_to_blob(task_id: str, video_url: str):
|
|
||||||
temp_file_path: Path | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 다운로드 및 업로드 로직
|
|
||||||
...
|
|
||||||
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
video.status = "completed"
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[EXCEPTION] {task_id}: {e}")
|
|
||||||
# 실패 상태로 업데이트
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
video.status = "failed"
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# 임시 파일 정리 (항상 실행)
|
|
||||||
if temp_file_path and temp_file_path.exists():
|
|
||||||
temp_file_path.unlink()
|
|
||||||
# 임시 디렉토리 정리
|
|
||||||
temp_dir.rmdir()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 쿼리 최적화 전략
|
|
||||||
|
|
||||||
### 6.1 N+1 문제 해결
|
|
||||||
|
|
||||||
**문제 코드:**
|
|
||||||
```python
|
|
||||||
# 각 video마다 project를 개별 조회 (N+1)
|
|
||||||
for video in videos:
|
|
||||||
project = await session.execute(
|
|
||||||
select(Project).where(Project.id == video.project_id)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**해결 코드:**
|
|
||||||
```python
|
|
||||||
# 1. Video 목록 조회
|
|
||||||
videos = await session.execute(video_query)
|
|
||||||
video_list = videos.scalars().all()
|
|
||||||
|
|
||||||
# 2. Project ID 수집
|
|
||||||
project_ids = [v.project_id for v in video_list if v.project_id]
|
|
||||||
|
|
||||||
# 3. Project 일괄 조회 (IN 절)
|
|
||||||
projects_result = await session.execute(
|
|
||||||
select(Project).where(Project.id.in_(project_ids))
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 딕셔너리로 매핑
|
|
||||||
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
|
||||||
|
|
||||||
# 5. 조합
|
|
||||||
for video in video_list:
|
|
||||||
project = projects_map.get(video.project_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
**위치**: `app/video/api/routers/v1/video.py` - `get_videos()`
|
|
||||||
|
|
||||||
### 6.2 서브쿼리를 활용한 중복 제거
|
|
||||||
|
|
||||||
```python
|
|
||||||
# task_id별 최신 Video의 id만 추출
|
|
||||||
subquery = (
|
|
||||||
select(func.max(Video.id).label("max_id"))
|
|
||||||
.where(Video.status == "completed")
|
|
||||||
.group_by(Video.task_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 최신 Video만 조회
|
|
||||||
query = (
|
|
||||||
select(Video)
|
|
||||||
.where(Video.id.in_(select(subquery.c.max_id)))
|
|
||||||
.order_by(Video.created_at.desc())
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 설계 강점 분석
|
|
||||||
|
|
||||||
### 7.1 안정성 (Stability)
|
|
||||||
|
|
||||||
| 요소 | 구현 | 효과 |
|
|
||||||
|------|------|------|
|
|
||||||
| pool_pre_ping | 쿼리 전 연결 검증 | Stale 커넥션 방지 |
|
|
||||||
| pool_reset_on_return | 반환 시 롤백 | 트랜잭션 상태 초기화 |
|
|
||||||
| 이중 커넥션 풀 | 요청/백그라운드 분리 | 리소스 경합 방지 |
|
|
||||||
| Finally 블록 | 임시 파일 정리 | 리소스 누수 방지 |
|
|
||||||
|
|
||||||
### 7.2 성능 (Performance)
|
|
||||||
|
|
||||||
| 요소 | 구현 | 효과 |
|
|
||||||
|------|------|------|
|
|
||||||
| asyncio.gather() | 병렬 쿼리 | 72% 응답 시간 단축 |
|
|
||||||
| 템플릿 캐싱 | TTL 기반 메모리 캐시 | API 호출 100% 감소 |
|
|
||||||
| HTTP 클라이언트 풀 | 싱글톤 패턴 | 커넥션 재사용 |
|
|
||||||
| N+1 해결 | IN 절 배치 조회 | 쿼리 수 N→2 감소 |
|
|
||||||
|
|
||||||
### 7.3 확장성 (Scalability)
|
|
||||||
|
|
||||||
| 요소 | 구현 | 효과 |
|
|
||||||
|------|------|------|
|
|
||||||
| 명시적 세션 관리 | 외부 API 시 세션 해제 | 커넥션 풀 점유 최소화 |
|
|
||||||
| 백그라운드 태스크 | FastAPI BackgroundTasks | 논블로킹 처리 |
|
|
||||||
| 폴링 패턴 | Status endpoint | 클라이언트 주도 동기화 |
|
|
||||||
|
|
||||||
### 7.4 유지보수성 (Maintainability)
|
|
||||||
|
|
||||||
| 요소 | 구현 | 효과 |
|
|
||||||
|------|------|------|
|
|
||||||
| 구조화된 로깅 | `[function_name]` prefix | 추적 용이 |
|
|
||||||
| 타입 힌트 | Python 3.11+ 문법 | IDE 지원, 버그 감소 |
|
|
||||||
| 문서화 | Docstring, 주석 | 코드 이해도 향상 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 개선 권장 사항
|
|
||||||
|
|
||||||
### 8.1 Song 라우터 N+1 문제
|
|
||||||
|
|
||||||
**현재 상태** (`app/song/api/routers/v1/song.py`):
|
|
||||||
```python
|
|
||||||
# N+1 발생
|
|
||||||
for song in songs:
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project).where(Project.id == song.project_id)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**권장 수정**:
|
|
||||||
```python
|
|
||||||
# video.py의 패턴 적용
|
|
||||||
project_ids = [s.project_id for s in songs if s.project_id]
|
|
||||||
projects_result = await session.execute(
|
|
||||||
select(Project).where(Project.id.in_(project_ids))
|
|
||||||
)
|
|
||||||
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 Suno 서비스 HTTP 클라이언트 풀링
|
|
||||||
|
|
||||||
**현재 상태** (`app/utils/suno.py`):
|
|
||||||
```python
|
|
||||||
# 요청마다 새 클라이언트 생성
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
**권장 수정**:
|
|
||||||
```python
|
|
||||||
# creatomate.py 패턴 적용
|
|
||||||
_suno_client: httpx.AsyncClient | None = None
|
|
||||||
|
|
||||||
async def get_suno_client() -> httpx.AsyncClient:
|
|
||||||
global _suno_client
|
|
||||||
if _suno_client is None or _suno_client.is_closed:
|
|
||||||
_suno_client = httpx.AsyncClient(
|
|
||||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
|
||||||
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
|
|
||||||
)
|
|
||||||
return _suno_client
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 동시성 제한
|
|
||||||
|
|
||||||
**권장 추가**:
|
|
||||||
```python
|
|
||||||
# 백그라운드 태스크 동시 실행 수 제한
|
|
||||||
BACKGROUND_TASK_SEMAPHORE = asyncio.Semaphore(5)
|
|
||||||
|
|
||||||
async def download_and_upload_video_to_blob(...):
|
|
||||||
async with BACKGROUND_TASK_SEMAPHORE:
|
|
||||||
# 기존 로직
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.4 분산 락 (선택적)
|
|
||||||
|
|
||||||
**높은 동시성 환경에서 권장**:
|
|
||||||
```python
|
|
||||||
# Redis 기반 분산 락
|
|
||||||
async def generate_video_with_lock(task_id: str):
|
|
||||||
lock_key = f"video_gen:{task_id}"
|
|
||||||
|
|
||||||
if not await redis.setnx(lock_key, "1"):
|
|
||||||
raise HTTPException(409, "Already processing")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await redis.expire(lock_key, 300) # 5분 TTL
|
|
||||||
# 영상 생성 로직
|
|
||||||
finally:
|
|
||||||
await redis.delete(lock_key)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 아키텍처 다이어그램
|
|
||||||
|
|
||||||
### 9.1 전체 요청 흐름
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ CLIENT │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ FASTAPI SERVER │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ ROUTERS │ │
|
|
||||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
|
||||||
│ │ │ /video │ │ /song │ │ /lyric │ │ /project │ │ │
|
|
||||||
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────────────┼───────────────┐ │
|
|
||||||
│ ▼ ▼ ▼ │
|
|
||||||
│ ┌─────────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │
|
|
||||||
│ │ MAIN ENGINE │ │ BACKGROUND │ │ EXTERNAL SERVICES │ │
|
|
||||||
│ │ (AsyncSession) │ │ ENGINE │ │ ┌───────────────┐ │ │
|
|
||||||
│ │ pool_size: 20 │ │ (BackgroundSess)│ │ │ Creatomate │ │ │
|
|
||||||
│ │ max_overflow: 20 │ │ pool_size: 10 │ │ ├───────────────┤ │ │
|
|
||||||
│ └─────────────────────┘ └─────────────────┘ │ │ Suno │ │ │
|
|
||||||
│ │ │ │ ├───────────────┤ │ │
|
|
||||||
│ ▼ ▼ │ │ ChatGPT │ │ │
|
|
||||||
│ ┌─────────────────────────────────────────┐ │ ├───────────────┤ │ │
|
|
||||||
│ │ MySQL DATABASE │ │ │ Azure Blob │ │ │
|
|
||||||
│ │ ┌────────┐ ┌────────┐ ┌────────────┐ │ │ └───────────────┘ │ │
|
|
||||||
│ │ │Project │ │ Song │ │ Video │ │ └─────────────────────┘ │
|
|
||||||
│ │ │ Lyric │ │ Image │ │ │ │ │
|
|
||||||
│ │ └────────┘ └────────┘ └────────────┘ │ │
|
|
||||||
│ └─────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.2 데이터 흐름
|
|
||||||
|
|
||||||
```
|
|
||||||
[영상 생성 파이프라인]
|
|
||||||
|
|
||||||
1. 프로젝트 생성
|
|
||||||
Client ─▶ POST /project ─▶ DB(Project) ─▶ task_id
|
|
||||||
|
|
||||||
2. 이미지 업로드
|
|
||||||
Client ─▶ POST /project/image ─▶ Azure Blob ─▶ DB(Image)
|
|
||||||
|
|
||||||
3. 가사 생성
|
|
||||||
Client ─▶ POST /lyric/generate ─▶ DB(Lyric) ─▶ BackgroundTask
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
ChatGPT API
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
DB Update
|
|
||||||
|
|
||||||
4. 노래 생성
|
|
||||||
Client ─▶ GET /song/generate/{task_id} ─▶ Suno API ─▶ DB(Song)
|
|
||||||
Client ◀──── polling ─────────────────────────────────┘
|
|
||||||
|
|
||||||
5. 영상 생성
|
|
||||||
Client ─▶ GET /video/generate/{task_id}
|
|
||||||
│
|
|
||||||
├─ asyncio.gather() ─▶ DB(Project, Lyric, Song, Image)
|
|
||||||
│
|
|
||||||
├─ Creatomate API ─▶ render_id
|
|
||||||
│
|
|
||||||
└─ DB(Video) status="processing"
|
|
||||||
|
|
||||||
Client ─▶ GET /video/status/{render_id}
|
|
||||||
│
|
|
||||||
├─ Creatomate Status Check
|
|
||||||
│
|
|
||||||
└─ if succeeded ─▶ BackgroundTask
|
|
||||||
│
|
|
||||||
├─ Download MP4
|
|
||||||
├─ Upload to Azure Blob
|
|
||||||
└─ DB Update status="completed"
|
|
||||||
|
|
||||||
6. 결과 조회
|
|
||||||
Client ─▶ GET /video/download/{task_id} ─▶ result_movie_url
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 결론
|
|
||||||
|
|
||||||
### 10.1 현재 아키텍처 평가
|
|
||||||
|
|
||||||
O2O-CASTAD Backend는 **프로덕션 준비 수준의 비동기 아키텍처**를 갖추고 있습니다:
|
|
||||||
|
|
||||||
1. **안정성**: 이중 커넥션 풀, pool_pre_ping, 명시적 세션 관리로 런타임 에러 최소화
|
|
||||||
2. **성능**: 병렬 쿼리, 캐싱, HTTP 클라이언트 풀링으로 응답 시간 최적화
|
|
||||||
3. **확장성**: 백그라운드 태스크 분리, 폴링 패턴으로 부하 분산
|
|
||||||
4. **유지보수성**: 일관된 패턴, 구조화된 로깅, 타입 힌트
|
|
||||||
|
|
||||||
### 10.2 핵심 성과
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ BEFORE → AFTER │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ Session Timeout Errors │ Frequent → Resolved │
|
|
||||||
│ DB Query Time │ 200ms → 55ms (72%↓) │
|
|
||||||
│ Template API Calls │ Every req → Cached (100%↓) │
|
|
||||||
│ HTTP Client Overhead │ 50ms/req → 0ms (100%↓) │
|
|
||||||
│ N+1 Query Problem │ N queries → 2 queries │
|
|
||||||
│ Connection Pool Conflicts │ Frequent → Isolated │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.3 권장 다음 단계
|
|
||||||
|
|
||||||
1. **단기**: Song 라우터 N+1 문제 해결
|
|
||||||
2. **단기**: Suno 서비스 HTTP 클라이언트 풀링 적용
|
|
||||||
3. **중기**: 동시성 제한 (Semaphore) 추가
|
|
||||||
4. **장기**: Redis 캐시 레이어 도입 (템플릿 캐시 영속화)
|
|
||||||
5. **장기**: 분산 락 구현 (높은 동시성 환경 대비)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **문서 끝**
|
|
||||||
> 추가 질문이나 개선 제안은 개발팀에 문의하세요.
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,500 +0,0 @@
|
||||||
# ORM 동기식 전환 보고서
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
현재 프로젝트는 **SQLAlchemy 2.0+ 비동기 방식**으로 구현되어 있습니다. 이 보고서는 동기식으로 전환할 경우 필요한 코드 수정 사항을 정리합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 현재 비동기 구현 현황
|
|
||||||
|
|
||||||
### 1.1 사용 중인 라이브러리
|
|
||||||
- `sqlalchemy[asyncio]>=2.0.45`
|
|
||||||
- `asyncmy>=0.2.10` (MySQL 비동기 드라이버)
|
|
||||||
- `aiomysql>=0.3.2`
|
|
||||||
|
|
||||||
### 1.2 주요 비동기 컴포넌트
|
|
||||||
| 컴포넌트 | 현재 (비동기) | 변경 후 (동기) |
|
|
||||||
|---------|-------------|--------------|
|
|
||||||
| 엔진 | `create_async_engine` | `create_engine` |
|
|
||||||
| 세션 팩토리 | `async_sessionmaker` | `sessionmaker` |
|
|
||||||
| 세션 클래스 | `AsyncSession` | `Session` |
|
|
||||||
| DB 드라이버 | `mysql+asyncmy` | `mysql+pymysql` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 파일별 수정 사항
|
|
||||||
|
|
||||||
### 2.1 pyproject.toml - 의존성 변경
|
|
||||||
|
|
||||||
**파일**: `pyproject.toml`
|
|
||||||
|
|
||||||
```diff
|
|
||||||
dependencies = [
|
|
||||||
"fastapi[standard]>=0.115.8",
|
|
||||||
- "sqlalchemy[asyncio]>=2.0.45",
|
|
||||||
+ "sqlalchemy>=2.0.45",
|
|
||||||
- "asyncmy>=0.2.10",
|
|
||||||
- "aiomysql>=0.3.2",
|
|
||||||
+ "pymysql>=1.1.0",
|
|
||||||
...
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.2 config.py - 데이터베이스 URL 변경
|
|
||||||
|
|
||||||
**파일**: `config.py` (라인 74-96)
|
|
||||||
|
|
||||||
```diff
|
|
||||||
class DatabaseSettings(BaseSettings):
|
|
||||||
@property
|
|
||||||
def MYSQL_URL(self) -> str:
|
|
||||||
- return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}"
|
|
||||||
+ return f"mysql+pymysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.3 app/database/session.py - 세션 설정 전면 수정
|
|
||||||
|
|
||||||
**파일**: `app/database/session.py`
|
|
||||||
|
|
||||||
#### 현재 코드 (비동기)
|
|
||||||
```python
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
engine = create_async_engine(
|
|
||||||
url=db_settings.MYSQL_URL,
|
|
||||||
echo=False,
|
|
||||||
pool_size=10,
|
|
||||||
max_overflow=10,
|
|
||||||
pool_timeout=5,
|
|
||||||
pool_recycle=3600,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
pool_reset_on_return="rollback",
|
|
||||||
)
|
|
||||||
|
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
|
||||||
bind=engine,
|
|
||||||
class_=AsyncSession,
|
|
||||||
expire_on_commit=False,
|
|
||||||
autoflush=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
raise e
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 변경 후 코드 (동기)
|
|
||||||
```python
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
engine = create_engine(
|
|
||||||
url=db_settings.MYSQL_URL,
|
|
||||||
echo=False,
|
|
||||||
pool_size=10,
|
|
||||||
max_overflow=10,
|
|
||||||
pool_timeout=5,
|
|
||||||
pool_recycle=3600,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
pool_reset_on_return="rollback",
|
|
||||||
)
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(
|
|
||||||
bind=engine,
|
|
||||||
class_=Session,
|
|
||||||
expire_on_commit=False,
|
|
||||||
autoflush=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_session() -> Generator[Session, None, None]:
|
|
||||||
with SessionLocal() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
except Exception as e:
|
|
||||||
session.rollback()
|
|
||||||
raise e
|
|
||||||
```
|
|
||||||
|
|
||||||
#### get_worker_session 함수 변경
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- from contextlib import asynccontextmanager
|
|
||||||
+ from contextlib import contextmanager
|
|
||||||
|
|
||||||
- @asynccontextmanager
|
|
||||||
- async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
- worker_engine = create_async_engine(
|
|
||||||
+ @contextmanager
|
|
||||||
+ def get_worker_session() -> Generator[Session, None, None]:
|
|
||||||
+ worker_engine = create_engine(
|
|
||||||
url=db_settings.MYSQL_URL,
|
|
||||||
poolclass=NullPool,
|
|
||||||
)
|
|
||||||
- session_factory = async_sessionmaker(
|
|
||||||
- bind=worker_engine,
|
|
||||||
- class_=AsyncSession,
|
|
||||||
+ session_factory = sessionmaker(
|
|
||||||
+ bind=worker_engine,
|
|
||||||
+ class_=Session,
|
|
||||||
expire_on_commit=False,
|
|
||||||
autoflush=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
- async with session_factory() as session:
|
|
||||||
+ with session_factory() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
- await session.close()
|
|
||||||
- await worker_engine.dispose()
|
|
||||||
+ session.close()
|
|
||||||
+ worker_engine.dispose()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.4 app/*/dependencies.py - 타입 힌트 변경
|
|
||||||
|
|
||||||
**파일**: `app/song/dependencies.py`, `app/lyric/dependencies.py`, `app/video/dependencies.py`
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
+ from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
- SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
||||||
+ SessionDep = Annotated[Session, Depends(get_session)]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.5 라우터 파일들 - async/await 제거
|
|
||||||
|
|
||||||
**영향받는 파일**:
|
|
||||||
- `app/home/api/routers/v1/home.py`
|
|
||||||
- `app/lyric/api/routers/v1/lyric.py`
|
|
||||||
- `app/song/api/routers/v1/song.py`
|
|
||||||
- `app/video/api/routers/v1/video.py`
|
|
||||||
|
|
||||||
#### 예시: lyric.py (라인 70-90)
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- async def get_lyric_by_task_id(
|
|
||||||
+ def get_lyric_by_task_id(
|
|
||||||
task_id: str,
|
|
||||||
- session: AsyncSession = Depends(get_session),
|
|
||||||
+ session: Session = Depends(get_session),
|
|
||||||
):
|
|
||||||
- result = await session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
|
||||||
+ result = session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
|
||||||
lyric = result.scalar_one_or_none()
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 예시: CRUD 작업 (라인 218-260)
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- async def create_project(
|
|
||||||
+ def create_project(
|
|
||||||
request_body: ProjectCreateRequest,
|
|
||||||
- session: AsyncSession = Depends(get_session),
|
|
||||||
+ session: Session = Depends(get_session),
|
|
||||||
):
|
|
||||||
project = Project(
|
|
||||||
store_name=request_body.customer_name,
|
|
||||||
region=request_body.region,
|
|
||||||
task_id=task_id,
|
|
||||||
)
|
|
||||||
session.add(project)
|
|
||||||
- await session.commit()
|
|
||||||
- await session.refresh(project)
|
|
||||||
+ session.commit()
|
|
||||||
+ session.refresh(project)
|
|
||||||
return project
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 예시: 플러시 작업 (home.py 라인 340-350)
|
|
||||||
|
|
||||||
```diff
|
|
||||||
session.add(image)
|
|
||||||
- await session.flush()
|
|
||||||
+ session.flush()
|
|
||||||
result = image.id
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.6 서비스 파일들 - Raw SQL 쿼리 변경
|
|
||||||
|
|
||||||
**영향받는 파일**:
|
|
||||||
- `app/lyric/services/lyrics.py`
|
|
||||||
- `app/song/services/song.py`
|
|
||||||
- `app/video/services/video.py`
|
|
||||||
|
|
||||||
#### 예시: lyrics.py (라인 20-30)
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- async def get_store_default_info(conn: AsyncConnection):
|
|
||||||
+ def get_store_default_info(conn: Connection):
|
|
||||||
query = """SELECT * FROM store_default_info;"""
|
|
||||||
- result = await conn.execute(text(query))
|
|
||||||
+ result = conn.execute(text(query))
|
|
||||||
return result.fetchall()
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 예시: INSERT 쿼리 (라인 360-400)
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- async def insert_song_result(conn: AsyncConnection, params: dict):
|
|
||||||
+ def insert_song_result(conn: Connection, params: dict):
|
|
||||||
insert_query = """INSERT INTO song_results_all (...) VALUES (...)"""
|
|
||||||
- await conn.execute(text(insert_query), params)
|
|
||||||
- await conn.commit()
|
|
||||||
+ conn.execute(text(insert_query), params)
|
|
||||||
+ conn.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.7 app/home/services/base.py - BaseService 클래스
|
|
||||||
|
|
||||||
**파일**: `app/home/services/base.py`
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
+ from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
class BaseService:
|
|
||||||
- def __init__(self, model, session: AsyncSession):
|
|
||||||
+ def __init__(self, model, session: Session):
|
|
||||||
self.model = model
|
|
||||||
self.session = session
|
|
||||||
|
|
||||||
- async def _get(self, id: UUID):
|
|
||||||
- return await self.session.get(self.model, id)
|
|
||||||
+ def _get(self, id: UUID):
|
|
||||||
+ return self.session.get(self.model, id)
|
|
||||||
|
|
||||||
- async def _add(self, entity):
|
|
||||||
+ def _add(self, entity):
|
|
||||||
self.session.add(entity)
|
|
||||||
- await self.session.commit()
|
|
||||||
- await self.session.refresh(entity)
|
|
||||||
+ self.session.commit()
|
|
||||||
+ self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
- async def _update(self, entity):
|
|
||||||
- return await self._add(entity)
|
|
||||||
+ def _update(self, entity):
|
|
||||||
+ return self._add(entity)
|
|
||||||
|
|
||||||
- async def _delete(self, entity):
|
|
||||||
- await self.session.delete(entity)
|
|
||||||
+ def _delete(self, entity):
|
|
||||||
+ self.session.delete(entity)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 모델 파일 - 변경 불필요
|
|
||||||
|
|
||||||
다음 모델 파일들은 **변경이 필요 없습니다**:
|
|
||||||
- `app/home/models.py`
|
|
||||||
- `app/lyric/models.py`
|
|
||||||
- `app/song/models.py`
|
|
||||||
- `app/video/models.py`
|
|
||||||
|
|
||||||
모델 정의 자체는 비동기/동기와 무관하게 동일합니다. `Mapped`, `mapped_column`, `relationship` 등은 그대로 사용 가능합니다.
|
|
||||||
|
|
||||||
단, **관계 로딩 전략**에서 `lazy="selectin"` 설정은 동기 환경에서도 작동하지만, 필요에 따라 `lazy="joined"` 또는 `lazy="subquery"`로 변경할 수 있습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 수정 패턴 요약
|
|
||||||
|
|
||||||
### 4.1 Import 변경 패턴
|
|
||||||
|
|
||||||
```diff
|
|
||||||
# 엔진/세션 관련
|
|
||||||
- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
||||||
+ from sqlalchemy import create_engine
|
|
||||||
+ from sqlalchemy.orm import Session, sessionmaker
|
|
||||||
|
|
||||||
# 타입 힌트
|
|
||||||
- from typing import AsyncGenerator
|
|
||||||
+ from typing import Generator
|
|
||||||
|
|
||||||
# 컨텍스트 매니저
|
|
||||||
- from contextlib import asynccontextmanager
|
|
||||||
+ from contextlib import contextmanager
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 함수 정의 변경 패턴
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- async def function_name(...):
|
|
||||||
+ def function_name(...):
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 await 제거 패턴
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- result = await session.execute(query)
|
|
||||||
+ result = session.execute(query)
|
|
||||||
|
|
||||||
- await session.commit()
|
|
||||||
+ session.commit()
|
|
||||||
|
|
||||||
- await session.refresh(obj)
|
|
||||||
+ session.refresh(obj)
|
|
||||||
|
|
||||||
- await session.flush()
|
|
||||||
+ session.flush()
|
|
||||||
|
|
||||||
- await session.rollback()
|
|
||||||
+ session.rollback()
|
|
||||||
|
|
||||||
- await session.close()
|
|
||||||
+ session.close()
|
|
||||||
|
|
||||||
- await engine.dispose()
|
|
||||||
+ engine.dispose()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 컨텍스트 매니저 변경 패턴
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- async with SessionLocal() as session:
|
|
||||||
+ with SessionLocal() as session:
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 영향받는 파일 목록
|
|
||||||
|
|
||||||
### 5.1 반드시 수정해야 하는 파일
|
|
||||||
|
|
||||||
| 파일 | 수정 범위 |
|
|
||||||
|-----|---------|
|
|
||||||
| `pyproject.toml` | 의존성 변경 |
|
|
||||||
| `config.py` | DB URL 변경 |
|
|
||||||
| `app/database/session.py` | 전면 수정 |
|
|
||||||
| `app/database/session-prod.py` | 전면 수정 |
|
|
||||||
| `app/home/api/routers/v1/home.py` | async/await 제거 |
|
|
||||||
| `app/lyric/api/routers/v1/lyric.py` | async/await 제거 |
|
|
||||||
| `app/song/api/routers/v1/song.py` | async/await 제거 |
|
|
||||||
| `app/video/api/routers/v1/video.py` | async/await 제거 |
|
|
||||||
| `app/lyric/services/lyrics.py` | async/await 제거 |
|
|
||||||
| `app/song/services/song.py` | async/await 제거 |
|
|
||||||
| `app/video/services/video.py` | async/await 제거 |
|
|
||||||
| `app/home/services/base.py` | async/await 제거 |
|
|
||||||
| `app/song/dependencies.py` | 타입 힌트 변경 |
|
|
||||||
| `app/lyric/dependencies.py` | 타입 힌트 변경 |
|
|
||||||
| `app/video/dependencies.py` | 타입 힌트 변경 |
|
|
||||||
| `app/dependencies/database.py` | 타입 힌트 변경 |
|
|
||||||
|
|
||||||
### 5.2 수정 불필요한 파일
|
|
||||||
|
|
||||||
| 파일 | 이유 |
|
|
||||||
|-----|-----|
|
|
||||||
| `app/home/models.py` | 모델 정의는 동기/비동기 무관 |
|
|
||||||
| `app/lyric/models.py` | 모델 정의는 동기/비동기 무관 |
|
|
||||||
| `app/song/models.py` | 모델 정의는 동기/비동기 무관 |
|
|
||||||
| `app/video/models.py` | 모델 정의는 동기/비동기 무관 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 주의사항
|
|
||||||
|
|
||||||
### 6.1 FastAPI와의 호환성
|
|
||||||
|
|
||||||
FastAPI는 동기 엔드포인트도 지원합니다. 동기 함수는 스레드 풀에서 실행됩니다:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 동기 엔드포인트 - FastAPI가 자동으로 스레드풀에서 실행
|
|
||||||
@router.get("/items/{item_id}")
|
|
||||||
def get_item(item_id: int, session: Session = Depends(get_session)):
|
|
||||||
return session.get(Item, item_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 성능 고려사항
|
|
||||||
|
|
||||||
동기식으로 전환 시 고려할 점:
|
|
||||||
- **동시성 감소**: 비동기 I/O의 이점 상실
|
|
||||||
- **스레드 풀 의존**: 동시 요청이 많을 경우 스레드 풀 크기 조정 필요
|
|
||||||
- **블로킹 I/O**: DB 쿼리 중 다른 요청 처리 불가
|
|
||||||
|
|
||||||
### 6.3 백그라운드 작업
|
|
||||||
|
|
||||||
현재 `get_worker_session()`으로 별도 이벤트 루프에서 실행되는 백그라운드 작업이 있습니다. 동기식 전환 시 스레드 기반 백그라운드 작업으로 변경해야 합니다:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
|
|
||||||
executor = ThreadPoolExecutor(max_workers=4)
|
|
||||||
|
|
||||||
def background_task():
|
|
||||||
with get_worker_session() as session:
|
|
||||||
# 작업 수행
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 실행
|
|
||||||
executor.submit(background_task)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 마이그레이션 단계
|
|
||||||
|
|
||||||
### Step 1: 의존성 변경
|
|
||||||
1. `pyproject.toml` 수정
|
|
||||||
2. `pip install pymysql` 또는 `uv sync` 실행
|
|
||||||
|
|
||||||
### Step 2: 설정 파일 수정
|
|
||||||
1. `config.py`의 DB URL 변경
|
|
||||||
2. `app/database/session.py` 전면 수정
|
|
||||||
|
|
||||||
### Step 3: 라우터 수정
|
|
||||||
1. 각 라우터 파일의 `async def` → `def` 변경
|
|
||||||
2. 모든 `await` 키워드 제거
|
|
||||||
3. `AsyncSession` → `Session` 타입 힌트 변경
|
|
||||||
|
|
||||||
### Step 4: 서비스 수정
|
|
||||||
1. 서비스 파일들의 async/await 제거
|
|
||||||
2. Raw SQL 쿼리 함수들 수정
|
|
||||||
|
|
||||||
### Step 5: 의존성 수정
|
|
||||||
1. `dependencies.py` 파일들의 타입 힌트 변경
|
|
||||||
|
|
||||||
### Step 6: 테스트
|
|
||||||
1. 모든 엔드포인트 기능 테스트
|
|
||||||
2. 성능 테스트 (동시 요청 처리 확인)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 결론
|
|
||||||
|
|
||||||
비동기에서 동기로 전환은 기술적으로 가능하지만, 다음을 고려해야 합니다:
|
|
||||||
|
|
||||||
**장점**:
|
|
||||||
- 코드 복잡도 감소 (async/await 제거)
|
|
||||||
- 디버깅 용이
|
|
||||||
- 레거시 라이브러리와의 호환성 향상
|
|
||||||
|
|
||||||
**단점**:
|
|
||||||
- 동시성 처리 능력 감소
|
|
||||||
- I/O 바운드 작업에서 성능 저하 가능
|
|
||||||
- FastAPI의 비동기 장점 미활용
|
|
||||||
|
|
||||||
현재 프로젝트가 FastAPI 기반이고 I/O 작업(DB, 외부 API 호출)이 많다면, **비동기 유지를 권장**합니다. 동기 전환은 특별한 요구사항(레거시 통합, 팀 역량 등)이 있을 때만 고려하시기 바랍니다.
|
|
||||||
|
|
@ -1,297 +0,0 @@
|
||||||
# 비동기 처리 문제 분석 보고서
|
|
||||||
|
|
||||||
## 요약
|
|
||||||
|
|
||||||
전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 심각도 높음 - 즉시 개선 권장
|
|
||||||
|
|
||||||
### 1.1 N+1 쿼리 문제 (video.py:596-612)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# get_videos() 엔드포인트에서
|
|
||||||
for video in videos:
|
|
||||||
# 매 video마다 별도의 DB 쿼리 실행 - N+1 문제!
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project).where(Project.id == video.project_id)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
```
|
|
||||||
|
|
||||||
**문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다.
|
|
||||||
|
|
||||||
**개선 방안**:
|
|
||||||
```python
|
|
||||||
# selectinload를 사용한 eager loading
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
query = (
|
|
||||||
select(Video)
|
|
||||||
.options(selectinload(Video.project)) # relationship 필요
|
|
||||||
.where(Video.id.in_(select(subquery.c.max_id)))
|
|
||||||
.order_by(Video.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(pagination.page_size)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 또는 한 번에 project_ids 수집 후 일괄 조회
|
|
||||||
project_ids = [v.project_id for v in videos]
|
|
||||||
projects_result = await session.execute(
|
|
||||||
select(Project).where(Project.id.in_(project_ids))
|
|
||||||
)
|
|
||||||
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨
|
|
||||||
print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}")
|
|
||||||
result = await service.generate(prompt=prompt) # 수 초~수십 초 소요
|
|
||||||
print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}")
|
|
||||||
```
|
|
||||||
|
|
||||||
**문제점**:
|
|
||||||
- ChatGPT API 응답이 5-30초 이상 걸릴 수 있음
|
|
||||||
- 이 시간 동안 클라이언트 연결이 유지되어야 함
|
|
||||||
- 다수 동시 요청 시 worker 스레드 고갈 가능성
|
|
||||||
|
|
||||||
**개선 방안 (BackgroundTask 패턴)**:
|
|
||||||
```python
|
|
||||||
@router.post("/generate")
|
|
||||||
async def generate_lyric(
|
|
||||||
request_body: GenerateLyricRequest,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> GenerateLyricResponse:
|
|
||||||
# DB에 processing 상태로 저장
|
|
||||||
lyric = Lyric(status="processing", ...)
|
|
||||||
session.add(lyric)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# 백그라운드에서 ChatGPT 호출
|
|
||||||
background_tasks.add_task(
|
|
||||||
generate_lyric_background,
|
|
||||||
task_id=task_id,
|
|
||||||
prompt=prompt,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 즉시 응답 반환
|
|
||||||
return GenerateLyricResponse(
|
|
||||||
success=True,
|
|
||||||
task_id=task_id,
|
|
||||||
message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py)
|
|
||||||
|
|
||||||
**문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다.
|
|
||||||
|
|
||||||
| 동기 메서드 | 비동기 메서드 |
|
|
||||||
|------------|--------------|
|
|
||||||
| `get_all_templates_data()` | 없음 |
|
|
||||||
| `get_one_template_data()` | `get_one_template_data_async()` |
|
|
||||||
| `make_creatomate_call()` | 없음 |
|
|
||||||
| `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` |
|
|
||||||
| `get_render_status()` | `get_render_status_async()` |
|
|
||||||
|
|
||||||
**개선 방안**:
|
|
||||||
```python
|
|
||||||
# 모든 HTTP 호출 메서드를 async로 통일
|
|
||||||
async def get_all_templates_data(self) -> dict:
|
|
||||||
url = f"{self.BASE_URL}/v1/templates"
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
# 동기 버전 제거 또는 deprecated 표시
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 심각도 중간 - 개선 권장
|
|
||||||
|
|
||||||
### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@asynccontextmanager
|
|
||||||
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
# 매 호출마다 새 엔진 생성 - 오버헤드 발생
|
|
||||||
worker_engine = create_async_engine(
|
|
||||||
url=db_settings.MYSQL_URL,
|
|
||||||
poolclass=NullPool,
|
|
||||||
...
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다.
|
|
||||||
|
|
||||||
**개선 방안**:
|
|
||||||
```python
|
|
||||||
# 모듈 레벨에서 워커 전용 엔진 생성
|
|
||||||
_worker_engine = create_async_engine(
|
|
||||||
url=db_settings.MYSQL_URL,
|
|
||||||
poolclass=NullPool,
|
|
||||||
)
|
|
||||||
_WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...)
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
async with _WorkerSessionLocal() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
raise e
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54)
|
|
||||||
|
|
||||||
```python
|
|
||||||
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)
|
|
||||||
```
|
|
||||||
|
|
||||||
**문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다.
|
|
||||||
|
|
||||||
**개선 방안 - 스트리밍 다운로드**:
|
|
||||||
```python
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
async with client.stream("GET", video_url, timeout=180.0) as response:
|
|
||||||
response.raise_for_status()
|
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
|
||||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
|
||||||
await f.write(chunk)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.3 httpx.AsyncClient 반복 생성
|
|
||||||
|
|
||||||
여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다.
|
|
||||||
|
|
||||||
**개선 방안 - 재사용 가능한 클라이언트**:
|
|
||||||
```python
|
|
||||||
# app/utils/http_client.py
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
_client: httpx.AsyncClient | None = None
|
|
||||||
|
|
||||||
async def get_http_client() -> httpx.AsyncClient:
|
|
||||||
global _client
|
|
||||||
if _client is None:
|
|
||||||
_client = httpx.AsyncClient(timeout=30.0)
|
|
||||||
return _client
|
|
||||||
|
|
||||||
async def close_http_client():
|
|
||||||
global _client
|
|
||||||
if _client:
|
|
||||||
await _client.aclose()
|
|
||||||
_client = None
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 심각도 낮음 - 선택적 개선
|
|
||||||
|
|
||||||
### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 4개의 개별 쿼리가 순차적으로 실행됨
|
|
||||||
project_result = await session.execute(select(Project).where(...))
|
|
||||||
lyric_result = await session.execute(select(Lyric).where(...))
|
|
||||||
song_result = await session.execute(select(Song).where(...))
|
|
||||||
image_result = await session.execute(select(Image).where(...))
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선 방안 - 병렬 쿼리 실행**:
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
project_task = session.execute(select(Project).where(Project.task_id == task_id))
|
|
||||||
lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id))
|
|
||||||
song_task = session.execute(
|
|
||||||
select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1)
|
|
||||||
)
|
|
||||||
image_task = session.execute(
|
|
||||||
select(Image).where(Image.task_id == task_id).order_by(Image.img_order.asc())
|
|
||||||
)
|
|
||||||
|
|
||||||
project_result, lyric_result, song_result, image_result = await asyncio.gather(
|
|
||||||
project_task, lyric_task, song_task, image_task
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.2 템플릿 조회 캐싱 미적용
|
|
||||||
|
|
||||||
`get_one_template_data_async()`가 매번 Creatomate API를 호출합니다.
|
|
||||||
|
|
||||||
**개선 방안 - 간단한 메모리 캐싱**:
|
|
||||||
```python
|
|
||||||
from functools import lru_cache
|
|
||||||
from cachetools import TTLCache
|
|
||||||
|
|
||||||
_template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시
|
|
||||||
|
|
||||||
async def get_one_template_data_async(self, template_id: str) -> dict:
|
|
||||||
if template_id in _template_cache:
|
|
||||||
return _template_cache[template_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()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
_template_cache[template_id] = data
|
|
||||||
return data
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 긍정적인 부분 (잘 구현된 패턴)
|
|
||||||
|
|
||||||
| 항목 | 상태 | 설명 |
|
|
||||||
|------|------|------|
|
|
||||||
| SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 |
|
|
||||||
| 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 |
|
|
||||||
| HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 |
|
|
||||||
| OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 |
|
|
||||||
| 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 |
|
|
||||||
| 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 |
|
|
||||||
| 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 우선순위별 개선 권장 사항
|
|
||||||
|
|
||||||
| 우선순위 | 항목 | 예상 효과 |
|
|
||||||
|----------|------|----------|
|
|
||||||
| **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 |
|
|
||||||
| **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 |
|
|
||||||
| **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 |
|
|
||||||
| **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 |
|
|
||||||
| **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 분석 일자
|
|
||||||
|
|
||||||
2024-12-29
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,100 +0,0 @@
|
||||||
import asyncio
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
from urllib import parse
|
|
||||||
|
|
||||||
class nvMapPwScraper():
|
|
||||||
# cls vars
|
|
||||||
is_ready = False
|
|
||||||
_playwright = None
|
|
||||||
_browser = None
|
|
||||||
_context = None
|
|
||||||
_win_width = 1280
|
|
||||||
_win_height = 720
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def default_context_builder(cls):
|
|
||||||
context_builder_dict = {}
|
|
||||||
context_builder_dict['viewport'] = {
|
|
||||||
'width' : cls._win_width,
|
|
||||||
'height' : cls._win_height
|
|
||||||
}
|
|
||||||
context_builder_dict['screen'] = {
|
|
||||||
'width' : cls._win_width,
|
|
||||||
'height' : cls._win_height
|
|
||||||
}
|
|
||||||
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
|
|
||||||
context_builder_dict['locale'] = 'ko-KR'
|
|
||||||
context_builder_dict['timezone_id']='Asia/Seoul'
|
|
||||||
|
|
||||||
return context_builder_dict
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def initiate_scraper(cls):
|
|
||||||
if not cls._playwright:
|
|
||||||
cls._playwright = await async_playwright().start()
|
|
||||||
if not cls._browser:
|
|
||||||
cls._browser = await cls._playwright.chromium.launch(headless=True)
|
|
||||||
if not cls._context:
|
|
||||||
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
|
||||||
cls.is_ready = True
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
if not self.is_ready:
|
|
||||||
raise Exception("nvMapScraper is not initiated")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_page(self):
|
|
||||||
while(not self.is_ready):
|
|
||||||
asyncio.sleep(1000)
|
|
||||||
self.page = await self._context.new_page()
|
|
||||||
await self.page.add_init_script(
|
|
||||||
'''const defaultGetter = Object.getOwnPropertyDescriptor(
|
|
||||||
Navigator.prototype,
|
|
||||||
"webdriver"
|
|
||||||
).get;
|
|
||||||
defaultGetter.apply(navigator);
|
|
||||||
defaultGetter.toString();
|
|
||||||
Object.defineProperty(Navigator.prototype, "webdriver", {
|
|
||||||
set: undefined,
|
|
||||||
enumerable: true,
|
|
||||||
configurable: true,
|
|
||||||
get: new Proxy(defaultGetter, {
|
|
||||||
apply: (target, thisArg, args) => {
|
|
||||||
Reflect.apply(target, thisArg, args);
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const patchedGetter = Object.getOwnPropertyDescriptor(
|
|
||||||
Navigator.prototype,
|
|
||||||
"webdriver"
|
|
||||||
).get;
|
|
||||||
patchedGetter.apply(navigator);
|
|
||||||
patchedGetter.toString();''')
|
|
||||||
|
|
||||||
await self.page.set_extra_http_headers({
|
|
||||||
'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
|
|
||||||
})
|
|
||||||
await self.page.goto("http://google.com")
|
|
||||||
|
|
||||||
async def goto_url(self, url):
|
|
||||||
page = self.page
|
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=20000)
|
|
||||||
|
|
||||||
async def get_place_id_url(self, selected):
|
|
||||||
|
|
||||||
title = selected['title'].replace("<b>", "").replace("</b>", "")
|
|
||||||
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
|
|
||||||
encoded_query = parse.quote(f"{address} {title}")
|
|
||||||
url = f"https://map.naver.com/p/search/{encoded_query}"
|
|
||||||
|
|
||||||
await self.goto_url(url)
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
while(count < 5):
|
|
||||||
if "isCorrectAnswer=true" in self.page.url:
|
|
||||||
return self.page.url
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
raise Exception("Failed to identify place id. item is ambiguous")
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
{
|
|
||||||
"cells": [
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 26,
|
|
||||||
"id": "99398cc7-e36a-494c-88f9-b26874ff0294",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"import aiohttp\n",
|
|
||||||
"import json"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 22,
|
|
||||||
"id": "28c3e49b-1133-4a18-ab70-fd321b4d2734",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"SUNO_API_KEY= '347da228e2d6ea273ef0558795a75892'\n",
|
|
||||||
"SUNO_BASE_URL=\"https://api.sunoapi.org\"\n",
|
|
||||||
"SUNO_TIMESTAPM_ROUTE = \"/api/v1/generate/get-timestamped-lyrics\"\n",
|
|
||||||
"SUNO_DETAIL_ROUTE = \"/api/v1/generate/record-info\"\n",
|
|
||||||
"suno_task_id = \"46bc90e6a2f9e9af58d7017e23f2115e\"\n"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 23,
|
|
||||||
"id": "fe09b1d5-7198-4c40-9667-d7d0885c62a3",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"headers = {\n",
|
|
||||||
" \"Authorization\": f\"Bearer {SUNO_API_KEY}\",\n",
|
|
||||||
" \"Content-Type\": \"application/json\",\n",
|
|
||||||
"}"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 24,
|
|
||||||
"id": "81bacedc-e488-4d04-84b1-8e8a06a64565",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"async def get_suno_audio_id_from_task_id(suno_task_id): # expire if db save audio id\n",
|
|
||||||
" url = f\"{SUNO_BASE_URL}{SUNO_DETAIL_ROUTE}\"\n",
|
|
||||||
" headers = {\"Authorization\": f\"Bearer {SUNO_API_KEY}\"}\n",
|
|
||||||
" async with aiohttp.ClientSession() as session:\n",
|
|
||||||
" async with session.get(url, headers=headers, params={\"taskId\" : suno_task_id}) as response:\n",
|
|
||||||
" detail = await response.json()\n",
|
|
||||||
" result = detail['data']['response']['sunoData'][0]['id']\n",
|
|
||||||
" return result "
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 25,
|
|
||||||
"id": "26346a13-0663-489f-98d0-69743dd8553f",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"async def get_suno_timestamp(suno_task_id, suno_audio_id): # expire if db save audio id\n",
|
|
||||||
" url = f\"{SUNO_BASE_URL}{SUNO_TIMESTAPM_ROUTE}\"\n",
|
|
||||||
" headers = {\"Authorization\": f\"Bearer {SUNO_API_KEY}\"}\n",
|
|
||||||
" payload = {\n",
|
|
||||||
" \"task_id\" : suno_task_id,\n",
|
|
||||||
" \"audio_id\" : suno_audio_id\n",
|
|
||||||
" }\n",
|
|
||||||
" async with aiohttp.ClientSession() as session:\n",
|
|
||||||
" async with session.post(url, headers=headers, data=json.dumps(payload)) as response:\n",
|
|
||||||
" result = await response.json()\n",
|
|
||||||
" return result"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 27,
|
|
||||||
"id": "78db0f6b-a54c-4415-9e82-972b00fefefb",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"data = await get_suno_timestamp(suno_task_id, await get_suno_audio_id_from_task_id(suno_task_id))"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 2,
|
|
||||||
"id": "44d8da8e-5a67-4125-809f-bbdb1efba55f",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"gt_lyric = \"\"\"\n",
|
|
||||||
"---\n",
|
|
||||||
"스테이,머뭄의 추억을 담아 \n",
|
|
||||||
"군산에서의 여행을 떠나보세 \n",
|
|
||||||
"인스타 감성 가득한 사진같은 하루, \n",
|
|
||||||
"힐링의 시간, 감성 숙소에서의 휴식\n",
|
|
||||||
"\n",
|
|
||||||
"은파호수공원의 자연 속, \n",
|
|
||||||
"시간이 멈춘 듯한 절골길을 걸어봐요 \n",
|
|
||||||
"Instagram vibes, 그림 같은 힐링 장소, \n",
|
|
||||||
"잊지 못할 여행 스토리 만들어지네\n",
|
|
||||||
"\n",
|
|
||||||
"넷이서 웃고 떠들던 그 날의 사진 속, \n",
|
|
||||||
"그 순간 훌쩍 떠나볼까요, 새로운 길로 \n",
|
|
||||||
"스테이,머뭄이 준비한 특별한 여행지 \n",
|
|
||||||
"몸과 마음이 따뜻해지는 그런 곳이에요 \n",
|
|
||||||
"---\n",
|
|
||||||
"\"\"\""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 20,
|
|
||||||
"id": "e4e9ba7d-964f-4f29-95f3-0f8514fad7ee",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"lyric_line_list = gt_lyric.split(\"\\n\")\n",
|
|
||||||
"lyric_line_list = [lyric_line.strip(',. ') for lyric_line in lyric_line_list if lyric_line and lyric_line != \"---\"]"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 21,
|
|
||||||
"id": "84a64cd5-7374-4c33-8634-6ac6ed0de425",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"['스테이,머뭄의 추억을 담아',\n",
|
|
||||||
" '군산에서의 여행을 떠나보세',\n",
|
|
||||||
" '인스타 감성 가득한 사진같은 하루',\n",
|
|
||||||
" '힐링의 시간, 감성 숙소에서의 휴식',\n",
|
|
||||||
" '은파호수공원의 자연 속',\n",
|
|
||||||
" '시간이 멈춘 듯한 절골길을 걸어봐요',\n",
|
|
||||||
" 'Instagram vibes, 그림 같은 힐링 장소',\n",
|
|
||||||
" '잊지 못할 여행 스토리 만들어지네',\n",
|
|
||||||
" '넷이서 웃고 떠들던 그 날의 사진 속',\n",
|
|
||||||
" '그 순간 훌쩍 떠나볼까요, 새로운 길로',\n",
|
|
||||||
" '스테이,머뭄이 준비한 특별한 여행지',\n",
|
|
||||||
" '몸과 마음이 따뜻해지는 그런 곳이에요']"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 21,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"lyric_line_list"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "17ca1a6e-c3a8-4683-958b-14bb3a46e63a",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"matching = "
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 35,
|
|
||||||
"id": "a8df83b4-99ef-4751-8c98-e5423c5c2494",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"aligned_words = data['data']['alignedWords']"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 36,
|
|
||||||
"id": "c1a1b2be-0796-4e40-b8bf-cd7c08e81e3e",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"ename": "_IncompleteInputError",
|
|
||||||
"evalue": "incomplete input (2013651467.py, line 9)",
|
|
||||||
"output_type": "error",
|
|
||||||
"traceback": [
|
|
||||||
" \u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[36]\u001b[39m\u001b[32m, line 9\u001b[39m\n\u001b[31m \u001b[39m\n ^\n\u001b[31m_IncompleteInputError\u001b[39m\u001b[31m:\u001b[39m incomplete input\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"alignment_lyric = {}\n",
|
|
||||||
"lyric_index = 0 \n",
|
|
||||||
"for aligned_word in aligned_words:\n",
|
|
||||||
" if not aligned_word['succsess']:\n",
|
|
||||||
" continue\n",
|
|
||||||
" if aligned_word['word'] in lyric_line_list[lyric_index]:\n",
|
|
||||||
" if lyric_index in alignment_lyric:\n",
|
|
||||||
" raise Exception\n",
|
|
||||||
" else:\n",
|
|
||||||
" \n",
|
|
||||||
" \n",
|
|
||||||
" \n",
|
|
||||||
" "
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "c59c4eb1-d916-4d3a-8d02-a212b45f20ba",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {
|
|
||||||
"display_name": "Python 3 (ipykernel)",
|
|
||||||
"language": "python",
|
|
||||||
"name": "python3"
|
|
||||||
},
|
|
||||||
"language_info": {
|
|
||||||
"codemirror_mode": {
|
|
||||||
"name": "ipython",
|
|
||||||
"version": 3
|
|
||||||
},
|
|
||||||
"file_extension": ".py",
|
|
||||||
"mimetype": "text/x-python",
|
|
||||||
"name": "python",
|
|
||||||
"nbconvert_exporter": "python",
|
|
||||||
"pygments_lexer": "ipython3",
|
|
||||||
"version": "3.13.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
from openai import OpenAI
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import List, Tuple
|
|
||||||
import aiohttp, json
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TimestampedLyric:
|
|
||||||
text: str
|
|
||||||
start: float
|
|
||||||
end: float
|
|
||||||
|
|
||||||
SUNO_BASE_URL="https://api.sunoapi.org"
|
|
||||||
SUNO_TIMESTAMP_ROUTE = "/api/v1/generate/get-timestamped-lyrics"
|
|
||||||
SUNO_DETAIL_ROUTE = "/api/v1/generate/record-info"
|
|
||||||
|
|
||||||
class LyricTimestampMapper:
|
|
||||||
suno_api_key : str
|
|
||||||
def __init__(self, suno_api_key):
|
|
||||||
self.suno_api_key = suno_api_key
|
|
||||||
|
|
||||||
async def get_suno_audio_id_from_task_id(self, suno_task_id): # expire if db save audio id
|
|
||||||
url = f"{SUNO_BASE_URL}{SUNO_DETAIL_ROUTE}"
|
|
||||||
headers = {"Authorization": f"Bearer {self.suno_api_key}"}
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(url, headers=headers, params={"taskId" : suno_task_id}) as response:
|
|
||||||
detail = await response.json()
|
|
||||||
result = detail['data']['response']['sunoData'][0]['id']
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def get_suno_timestamp(self, suno_task_id, suno_audio_id): # expire if db save audio id
|
|
||||||
url = f"{SUNO_BASE_URL}{SUNO_TIMESTAMP_ROUTE}"
|
|
||||||
headers = {"Authorization": f"Bearer {self.suno_api_key}"}
|
|
||||||
payload = {
|
|
||||||
"task_id" : suno_task_id,
|
|
||||||
"audio_id" : suno_audio_id
|
|
||||||
}
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(url, headers=headers, data=json.dumps(payload)) as response:
|
|
||||||
result = await response.json()
|
|
||||||
return result
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
from lyric_timestamp_mapper import LyricTimestampMapper
|
|
||||||
|
|
||||||
API_KEY = "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"
|
|
||||||
AUDIO_PATH = "test_audio.mp3"
|
|
||||||
|
|
||||||
GROUND_TRUTH_LYRICS = [
|
|
||||||
"첫 번째 가사 라인입니다",
|
|
||||||
"두 번째 가사 라인입니다",
|
|
||||||
"세 번째 가사 라인입니다",
|
|
||||||
]
|
|
||||||
|
|
||||||
mapper = LyricTimestampMapper(api_key=API_KEY)
|
|
||||||
result = mapper.map_ground_truth(AUDIO_PATH, GROUND_TRUTH_LYRICS)
|
|
||||||
|
|
||||||
for lyric in result:
|
|
||||||
if lyric.start >= 0:
|
|
||||||
print(f"[{lyric.start:.2f} - {lyric.end:.2f}] {lyric.text}")
|
|
||||||
else:
|
|
||||||
print(f"[매칭 실패] {lyric.text}")
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
SAS_TOKEN = "sp=racwdl&st=2025-12-01T00:13:29Z&se=2026-07-31T08:28:29Z&spr=https&sv=2024-11-04&sr=c&sig=7fE2ozVBPu3Gq43%2FZDxEYdEcPLDXyNVfTf16IBasmVQ%3D"
|
|
||||||
|
|
||||||
URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/"
|
|
||||||
|
|
||||||
|
|
||||||
def upload_music_to_azure_blob(
|
|
||||||
file_path="스테이 머뭄_1.mp3",
|
|
||||||
url="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/test_my_mp3_should_now_exist.mp3",
|
|
||||||
):
|
|
||||||
access_url = f"{url}?{SAS_TOKEN}"
|
|
||||||
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
|
|
||||||
with open(file_path, "rb") as file:
|
|
||||||
response = requests.put(access_url, data=file, headers=headers)
|
|
||||||
if response.status_code in [200, 201]:
|
|
||||||
print(f"Success Status Code: {response.status_code}")
|
|
||||||
else:
|
|
||||||
print(f"Failed Status Code: {response.status_code}")
|
|
||||||
print(f"Response: {response.text}")
|
|
||||||
|
|
||||||
|
|
||||||
def upload_video_to_azure_blob(
|
|
||||||
file_path="스테이 머뭄.mp4",
|
|
||||||
url="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/test_my_mp3_should_now_exist.mp4",
|
|
||||||
):
|
|
||||||
access_url = f"{url}?{SAS_TOKEN}"
|
|
||||||
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
|
|
||||||
with open(file_path, "rb") as file:
|
|
||||||
response = requests.put(access_url, data=file, headers=headers)
|
|
||||||
|
|
||||||
if response.status_code in [200, 201]:
|
|
||||||
print(f"Success Status Code: {response.status_code}")
|
|
||||||
else:
|
|
||||||
print(f"Failed Status Code: {response.status_code}")
|
|
||||||
print(f"Response: {response.text}")
|
|
||||||
|
|
||||||
|
|
||||||
def upload_image_to_azure_blob(
|
|
||||||
file_path="스테이 머뭄.png",
|
|
||||||
url="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/test_my_mp3_should_now_exist.png",
|
|
||||||
):
|
|
||||||
access_url = f"{url}?{SAS_TOKEN}"
|
|
||||||
extension = Path(file_path).suffix.lower()
|
|
||||||
content_types = {
|
|
||||||
".jpg": "image/jpeg",
|
|
||||||
".jpeg": "image/jpeg",
|
|
||||||
".png": "image/png",
|
|
||||||
".gif": "image/gif",
|
|
||||||
".webp": "image/webp",
|
|
||||||
".bmp": "image/bmp",
|
|
||||||
}
|
|
||||||
content_type = content_types.get(extension, "image/jpeg")
|
|
||||||
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
|
||||||
with open(file_path, "rb") as file:
|
|
||||||
response = requests.put(access_url, data=file, headers=headers)
|
|
||||||
|
|
||||||
if response.status_code in [200, 201]:
|
|
||||||
print(f"Success Status Code: {response.status_code}")
|
|
||||||
else:
|
|
||||||
print(f"Failed Status Code: {response.status_code}")
|
|
||||||
print(f"Response: {response.text}")
|
|
||||||
|
|
||||||
|
|
||||||
upload_video_to_azure_blob()
|
|
||||||
|
|
||||||
upload_image_to_azure_blob()
|
|
||||||
Loading…
Reference in New Issue