""" Video API Router 이 모듈은 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 fastapi import APIRouter, BackgroundTasks, Depends, HTTPException 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 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, GenerateVideoRequest, 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 router = APIRouter(prefix="/video", tags=["video"]) @router.post( "/generate/{task_id}", summary="영상 생성 요청", description=""" Creatomate API를 통해 영상 생성을 요청합니다. ## 경로 파라미터 - **task_id**: Project/Lyric/Song의 task_id (필수) - 연관된 프로젝트, 가사, 노래를 조회하는 데 사용 ## 요청 필드 - **orientation**: 영상 방향 (horizontal: 가로형, vertical: 세로형, 기본값: vertical) - 선택 - **image_urls**: 영상에 사용할 이미지 URL 목록 (필수) - **lyrics**: 영상에 표시할 가사 (필수) - **music_url**: 배경 음악 URL (필수) ## 반환 정보 - **success**: 요청 성공 여부 - **task_id**: 내부 작업 ID (Project task_id) - **creatomate_render_id**: Creatomate 렌더 ID (상태 조회에 사용) - **message**: 응답 메시지 ## 사용 예시 ``` POST /video/generate/019123ab-cdef-7890-abcd-ef1234567890 { "image_urls": [ "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg", "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg", "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg", "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg", "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "https://naverbooking-phinf.pstatic.net/20240514_64/1715688031601oGNsV_JPEG/6.jpg", "https://naverbooking-phinf.pstatic.net/20240514_175/1715688031657oXc7l_JPEG/7.jpg", "https://naverbooking-phinf.pstatic.net/20240514_192/1715688031798MbFDj_JPEG/8.jpg", "https://naverbooking-phinf.pstatic.net/20240514_205/17156880318681JLwX_JPEG/9.jpg", "https://naverbooking-phinf.pstatic.net/20240514_142/1715688031946hhxHz_JPEG/10.jpg" ], "lyrics": "가사 내용...", "music_url": "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/0694e2d8-7ae2-730c-8000-308aacaa582d/song/스테이 머뭄.mp3" } ``` ## 가로형 영상 생성 예시 ``` POST /video/generate/019123ab-cdef-7890-abcd-ef1234567890 { "orientation": "horizontal", "image_urls": [...], "lyrics": "가사 내용...", "music_url": "https://..." } ``` ## 참고 - creatomate_render_id를 사용하여 /status/{creatomate_render_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다. - Video 테이블에 데이터가 저장되며, project_id, lyric_id, song_id가 자동으로 연결됩니다. """, response_model=GenerateVideoResponse, responses={ 200: {"description": "영상 생성 요청 성공"}, 404: {"description": "Project, Lyric 또는 Song을 찾을 수 없음"}, 500: {"description": "영상 생성 요청 실패"}, }, ) async def generate_video( task_id: str, request_body: GenerateVideoRequest, session: AsyncSession = Depends(get_session), ) -> GenerateVideoResponse: """Creatomate API를 통해 영상을 생성합니다. 1. task_id로 Project, Lyric, Song 조회 2. Video 테이블에 초기 데이터 저장 (status: processing) 3. Creatomate API 호출 (orientation에 따른 템플릿 자동 선택) 4. creatomate_render_id 업데이트 후 응답 반환 """ print(f"[generate_video] START - task_id: {task_id}, orientation: {request_body.orientation}") try: # 1. task_id로 Project 조회 project_result = await session.execute( select(Project).where(Project.task_id == task_id) ) 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를 찾을 수 없습니다.", ) print(f"[generate_video] Project found - project_id: {project.id}, task_id: {task_id}") # 2. task_id로 Lyric 조회 lyric_result = await session.execute( select(Lyric).where(Lyric.task_id == task_id) ) 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을 찾을 수 없습니다.", ) print(f"[generate_video] Lyric found - lyric_id: {lyric.id}, task_id: {task_id}") # 3. task_id로 Song 조회 (가장 최근 것) song_result = await session.execute( select(Song) .where(Song.task_id == task_id) .order_by(Song.created_at.desc()) .limit(1) ) 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을 찾을 수 없습니다.", ) print(f"[generate_video] Song found - song_id: {song.id}, task_id: {task_id}, duration: {song.duration}") print(f"[generate_video] Music URL: {request_body.music_url}, Song duration: {song.duration}") # 4. 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.flush() # ID 생성을 위해 flush print(f"[generate_video] Video saved (processing) - task_id: {task_id}") # 5. Creatomate API 호출 (POC 패턴 적용) print(f"[generate_video] Creatomate API generation started - task_id: {task_id}") # orientation에 따른 템플릿 선택, duration은 Song에서 가져옴 (없으면 config 기본값 사용) creatomate_service = CreatomateService( orientation=request_body.orientation, target_duration=song.duration, # Song의 duration 사용 (None이면 config 기본값) ) print(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song.duration})") # 5-1. 템플릿 조회 (비동기, CreatomateService에서 orientation에 맞는 template_id 사용) template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id) print(f"[generate_video] Template fetched - task_id: {task_id}") # 5-2. elements에서 리소스 매핑 생성 modifications = creatomate_service.elements_connect_resource_blackbox( elements=template["source"]["elements"], image_url_list=request_body.image_urls, lyric=request_body.lyrics, music_url=request_body.music_url, ) print(f"[generate_video] Modifications created - task_id: {task_id}") # 5-3. elements 수정 new_elements = creatomate_service.modify_element( template["source"]["elements"], modifications, ) template["source"]["elements"] = new_elements print(f"[generate_video] Elements modified - task_id: {task_id}") # 5-4. duration 확장 (target_duration: 영상 길이) final_template = creatomate_service.extend_template_duration( template, creatomate_service.target_duration, ) print(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}") # 5-5. 커스텀 렌더링 요청 (비동기) render_response = await creatomate_service.make_creatomate_custom_call_async( final_template["source"], ) print(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}") # 렌더 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 # 6. creatomate_render_id 업데이트 video.creatomate_render_id = creatomate_render_id await session.commit() print(f"[generate_video] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}") return GenerateVideoResponse( success=True, task_id=task_id, creatomate_render_id=creatomate_render_id, message="영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.", error_message=None, ) except HTTPException: raise except Exception as e: print(f"[generate_video] EXCEPTION - task_id: {task_id}, error: {e}") await session.rollback() return GenerateVideoResponse( success=False, task_id=task_id, creatomate_render_id=None, message="영상 생성 요청에 실패했습니다.", error_message=str(e), ) @router.get( "/status/{creatomate_render_id}", summary="영상 생성 상태 조회", description=""" 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( creatomate_render_id: str, background_tasks: BackgroundTasks, 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, ) # 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", message="영상 다운로드가 완료되었습니다.", 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( "s/", summary="생성된 영상 목록 조회", description=""" 완료된 영상 목록을 페이지네이션하여 조회합니다. ## 쿼리 파라미터 - **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( session: AsyncSession = Depends(get_session), pagination: PaginationParams = Depends(get_pagination_params), ) -> PaginatedResponse[VideoListItem]: """완료된 영상 목록을 페이지네이션하여 반환합니다.""" print(f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}") try: offset = (pagination.page - 1) * pagination.page_size # 서브쿼리: task_id별 최신 Video의 id 조회 (completed 상태만) subquery = ( select(func.max(Video.id).label("max_id")) .where(Video.status == "completed") .group_by(Video.task_id) .subquery() ) # 전체 개수 조회 (task_id별 최신 1개만) count_query = select(func.count()).select_from(subquery) total_result = await session.execute(count_query) 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 정보와 함께 VideoListItem으로 변환 items = [] for video in videos: # Project 조회 (video.project_id 직접 사용) project_result = await session.execute( select(Project).where(Project.id == video.project_id) ) project = project_result.scalar_one_or_none() 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) # 개별 아이템 로그 print( f"[get_videos] Item - store_name: {item.store_name}, region: {item.region}, " f"task_id: {item.task_id}, result_movie_url: {item.result_movie_url}, " f"created_at: {item.created_at}" ) 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)}", )