diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index de78f04..6f4b0ce 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -1,15 +1,27 @@ +import json +from datetime import date from pathlib import Path +from typing import Optional +from urllib.parse import unquote, urlparse -from fastapi import APIRouter +import aiofiles +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status +from sqlalchemy.ext.asyncio import AsyncSession -from app.home.schemas.home import ( +from app.database.session import get_session +from app.home.models import Image +from app.home.schemas.home_schema import ( CrawlingRequest, CrawlingResponse, ErrorResponse, + ImageUploadResponse, + ImageUploadResultItem, + ImageUrlItem, MarketingAnalysis, ProcessedInfo, ) from app.utils.chatgpt_prompt import ChatgptService +from app.utils.common import generate_task_id from app.utils.nvMapScraper import NvMapScraper MEDIA_ROOT = Path("media") @@ -123,8 +135,6 @@ async def crawling(request_body: CrawlingRequest): def _extract_image_name(url: str, index: int) -> str: """URL에서 이미지 이름 추출 또는 기본 이름 생성""" try: - from urllib.parse import unquote, urlparse - path = urlparse(url).path filename = path.split("/")[-1] if path else "" if filename: @@ -134,6 +144,278 @@ def _extract_image_name(url: str, index: int) -> str: return f"image_{index + 1:03d}" +ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"} + + +def _is_valid_image_extension(filename: str | None) -> bool: + """파일명의 확장자가 유효한 이미지 확장자인지 확인""" + if not filename: + return False + ext = Path(filename).suffix.lower() + return ext in ALLOWED_IMAGE_EXTENSIONS + + +def _get_file_extension(filename: str) -> str: + """파일명에서 확장자 추출 (소문자)""" + return Path(filename).suffix.lower() + + +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) + + +IMAGES_JSON_EXAMPLE = """[ + {"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"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"} +]""" + + +@router.post( + "/image/{task_id}/upload", + summary="이미지 업로드", + description=""" +task_id에 연결된 이미지를 업로드합니다. + +## 요청 방식 +multipart/form-data 형식으로 전송합니다. + +## 요청 필드 +- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택) +- **files**: 이미지 바이너리 파일 목록 (선택) + +**주의**: images_json 또는 files 중 최소 하나는 반드시 전달해야 합니다. + +## 지원 이미지 확장자 +jpg, jpeg, png, webp, heic, heif + +## images_json 예시 +```json +[ + {"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"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"}, + {"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"} +] +``` + +## 바이너리 파일 업로드 테스트 방법 + +### 1. Swagger UI에서 테스트 +1. 이 엔드포인트의 "Try it out" 버튼 클릭 +2. task_id 입력 (예: test-task-001) +3. files 항목에서 "Add item" 클릭하여 로컬 이미지 파일 선택 +4. (선택) images_json에 URL 목록 JSON 입력 +5. "Execute" 버튼 클릭 + +### 2. cURL로 테스트 +```bash +# 바이너리 파일만 업로드 +curl -X POST "http://localhost:8000/image/test-task-001/upload" \\ + -F "files=@/path/to/image1.jpg" \\ + -F "files=@/path/to/image2.png" + +# URL + 바이너리 파일 동시 업로드 +curl -X POST "http://localhost:8000/image/test-task-001/upload" \\ + -F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\ + -F "files=@/path/to/local_image.jpg" +``` + +### 3. Python requests로 테스트 +```python +import requests + +url = "http://localhost:8000/image/test-task-001/upload" +files = [ + ("files", ("image1.jpg", open("image1.jpg", "rb"), "image/jpeg")), + ("files", ("image2.png", open("image2.png", "rb"), "image/png")), +] +data = { + "images_json": '[{"url": "https://example.com/image.jpg"}]' +} +response = requests.post(url, files=files, data=data) +print(response.json()) +``` + +## 반환 정보 +- **task_id**: 작업 고유 식별자 +- **total_count**: 총 업로드된 이미지 개수 +- **url_count**: URL로 등록된 이미지 개수 +- **file_count**: 파일로 업로드된 이미지 개수 +- **images**: 업로드된 이미지 목록 + +## 저장 경로 +- 바이너리 파일: /media/{날짜}/{uuid7}/{파일명} + """, + response_model=ImageUploadResponse, + responses={ + 200: {"description": "이미지 업로드 성공"}, + 400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse}, + }, + tags=["image"], +) +async def upload_images( + task_id: str, + images_json: Optional[str] = Form( + default=None, + description="외부 이미지 URL 목록 (JSON 문자열)", + example=IMAGES_JSON_EXAMPLE, + ), + files: Optional[list[UploadFile]] = File( + default=None, description="이미지 바이너리 파일 목록" + ), + session: AsyncSession = Depends(get_session), +) -> ImageUploadResponse: + """이미지 업로드 (URL + 바이너리 파일)""" + print(f"[upload_images] START - task_id: {task_id}") + print(f"[upload_images] images_json: {images_json}") + print(f"[upload_images] files: {files}") + + # 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 + print(f"[upload_images] has_images_json: {has_images_json}, has_files: {has_files}") + + if has_files and files: + for idx, f in enumerate(files): + print(f"[upload_images] file[{idx}]: filename={f.filename}, size={f.size}, content_type={f.content_type}") + + if not has_images_json and not has_files: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.", + ) + + # 2. images_json 파싱 (있는 경우만) + url_images: list[ImageUrlItem] = [] + if has_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)}", + ) + + # 3. 유효한 파일만 필터링 (빈 파일, 유효한 이미지 확장자가 아닌 경우 제외) + valid_files: list[UploadFile] = [] + 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 # size가 None이면 아직 읽지 않은 것 + is_real_file = f.filename and f.filename != "filename" # Swagger 빈 파일 체크 + print(f"[upload_images] Checking file: {f.filename}, size={f.size}, is_valid_ext={is_valid_ext}, is_real_file={is_real_file}") + + if f and is_real_file and is_valid_ext and is_not_empty: + valid_files.append(f) + else: + skipped_files.append(f.filename or "unknown") + + print(f"[upload_images] valid_files count: {len(valid_files)}, skipped: {skipped_files}") + + # 유효한 데이터가 하나도 없으면 에러 + if not url_images and not valid_files: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"유효한 이미지가 없습니다. 지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. 건너뛴 파일: {skipped_files}", + ) + + result_images: list[ImageUploadResultItem] = [] + img_order = 0 + + # 1. 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() # ID 생성을 위해 flush + print(f"[upload_images] URL image saved - id: {image.id}, img_name: {img_name}") + + 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 + + # 2. 바이너리 파일 저장 + if valid_files: + today = date.today().strftime("%Y-%m-%d") + # 한 번의 요청에서 업로드된 모든 이미지는 같은 폴더에 저장 + batch_uuid = await generate_task_id() + upload_dir = MEDIA_ROOT / "image" / today / batch_uuid + upload_dir.mkdir(parents=True, exist_ok=True) + + for file in valid_files: + # 파일명: 원본 파일명 사용 (중복 방지를 위해 순서 추가) + original_name = file.filename or "image" + ext = _get_file_extension(file.filename) # type: ignore[arg-type] + # 파일명에서 확장자 제거 후 순서 추가 + name_without_ext = original_name.rsplit(".", 1)[0] if "." in original_name else original_name + filename = f"{name_without_ext}_{img_order:03d}{ext}" + + save_path = upload_dir / filename + + # 파일 저장 + await _save_upload_file(file, save_path) + + # 이미지 URL 생성 + img_url = f"/media/image/{today}/{batch_uuid}/{filename}" + img_name = file.filename or filename + + image = Image( + task_id=task_id, + img_name=img_name, + img_url=img_url, + img_order=img_order, + ) + session.add(image) + await session.flush() + + result_images.append( + ImageUploadResultItem( + id=image.id, + img_name=img_name, + img_url=img_url, + img_order=img_order, + source="file", + ) + ) + img_order += 1 + + print(f"[upload_images] Committing {len(result_images)} images to database...") + await session.commit() + print("[upload_images] Commit successful!") + + return ImageUploadResponse( + task_id=task_id, + total_count=len(result_images), + url_count=len(url_images), + file_count=len(valid_files), + images=result_images, + ) + + # @router.post( # "/generate", # summary="기본 영상 생성 요청", diff --git a/app/home/schemas/home.py b/app/home/schemas/home_schema.py similarity index 74% rename from app/home/schemas/home.py rename to app/home/schemas/home_schema.py index 89a17c1..cf1a2a3 100644 --- a/app/home/schemas/home.py +++ b/app/home/schemas/home_schema.py @@ -159,3 +159,58 @@ class ErrorResponse(BaseModel): error_code: str = Field(..., description="에러 코드") message: str = Field(..., 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"] = Field(..., description="이미지 소스 (url 또는 file)") + + +class ImageUploadResponse(BaseModel): + """이미지 업로드 응답 스키마""" + + task_id: str = Field(..., description="작업 고유 식별자") + total_count: int = Field(..., description="총 업로드된 이미지 개수") + url_count: int = Field(..., description="URL로 등록된 이미지 개수") + file_count: int = Field(..., description="파일로 업로드된 이미지 개수") + images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록") diff --git a/app/home/worker/main_task.py b/app/home/worker/main_task.py index 84c77d8..3cebcb4 100644 --- a/app/home/worker/main_task.py +++ b/app/home/worker/main_task.py @@ -3,7 +3,7 @@ import asyncio from sqlalchemy import select from app.database.session import get_worker_session -from app.home.schemas.home import GenerateRequest +from app.home.schemas.home_schema import GenerateRequest from app.lyric.models import Lyric from app.utils.chatgpt_prompt import ChatgptService diff --git a/app/song/worker/song_task.py b/app/song/worker/song_task.py index 05a3a77..c71ec32 100644 --- a/app/song/worker/song_task.py +++ b/app/song/worker/song_task.py @@ -31,8 +31,8 @@ async def download_and_save_song( """ print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}") try: - # 저장 경로 생성: media/{날짜}/{uuid7}/{store_name}.mp3 - today = date.today().isoformat() + # 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3 + today = date.today().strftime("%Y-%m-%d") unique_id = await generate_task_id() # 파일명에 사용할 수 없는 문자 제거 safe_store_name = "".join( @@ -42,7 +42,7 @@ async def download_and_save_song( file_name = f"{safe_store_name}.mp3" # 절대 경로 생성 - media_dir = Path("media") / today / unique_id + media_dir = Path("media") / "song" / today / unique_id media_dir.mkdir(parents=True, exist_ok=True) file_path = media_dir / file_name print(f"[download_and_save_song] Directory created - path: {file_path}") @@ -58,7 +58,7 @@ async def download_and_save_song( print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}") # 프론트엔드에서 접근 가능한 URL 생성 - relative_path = f"/media/{today}/{unique_id}/{file_name}" + relative_path = f"/media/song/{today}/{unique_id}/{file_name}" base_url = f"http://{prj_settings.PROJECT_DOMAIN}" file_url = f"{base_url}{relative_path}" print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}") diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index 13ee715..ceebef3 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -209,6 +209,29 @@ class CreatomateService: response.raise_for_status() return response.json() + 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}" + response = httpx.get(url, headers=self.headers, timeout=30.0) + response.raise_for_status() + return response.json() + def calc_scene_duration(self, template: dict) -> float: """템플릿의 전체 장면 duration을 계산합니다.""" total_template_duration = 0.0 diff --git a/app/utils/upload_blob_as_request.py b/app/utils/upload_blob_as_request.py index 789c05e..412ea7e 100644 --- a/app/utils/upload_blob_as_request.py +++ b/app/utils/upload_blob_as_request.py @@ -1,39 +1,92 @@ -import requests +""" +Azure Blob Storage 업로드 유틸리티 + +Azure Blob Storage에 파일을 업로드하는 비동기 함수들을 제공합니다. +""" + from pathlib import Path +import aiofiles +import httpx + 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" -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"): + +async def upload_music_to_azure_blob( + file_path: str = "스테이 머뭄_1.mp3", + url: str = "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", +) -> bool: + """음악 파일을 Azure Blob Storage에 업로드합니다. + + Args: + file_path: 업로드할 파일 경로 + url: Azure Blob Storage URL + + Returns: + bool: 업로드 성공 여부 + """ 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}") + headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"} -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"): + async with aiofiles.open(file_path, "rb") as file: + file_content = await file.read() + + async with httpx.AsyncClient() as client: + response = await client.put(access_url, content=file_content, headers=headers, timeout=120.0) + + if response.status_code in [200, 201]: + print(f"[upload_music_to_azure_blob] Success - Status Code: {response.status_code}") + return True + else: + print(f"[upload_music_to_azure_blob] Failed - Status Code: {response.status_code}") + print(f"[upload_music_to_azure_blob] Response: {response.text}") + return False + + +async def upload_video_to_azure_blob( + file_path: str = "스테이 머뭄.mp4", + url: str = "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", +) -> bool: + """영상 파일을 Azure Blob Storage에 업로드합니다. + + Args: + file_path: 업로드할 파일 경로 + url: Azure Blob Storage URL + + Returns: + bool: 업로드 성공 여부 + """ 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) - + headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"} + + async with aiofiles.open(file_path, "rb") as file: + file_content = await file.read() + + async with httpx.AsyncClient() as client: + response = await client.put(access_url, content=file_content, headers=headers, timeout=180.0) + if response.status_code in [200, 201]: - print(f"Success Status Code: {response.status_code}") + print(f"[upload_video_to_azure_blob] Success - Status Code: {response.status_code}") + return True else: - print(f"Failed Status Code: {response.status_code}") - print(f"Response: {response.text}") + print(f"[upload_video_to_azure_blob] Failed - Status Code: {response.status_code}") + print(f"[upload_video_to_azure_blob] Response: {response.text}") + return False -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"): +async def upload_image_to_azure_blob( + file_path: str = "스테이 머뭄.png", + url: str = "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", +) -> bool: + """이미지 파일을 Azure Blob Storage에 업로드합니다. + + Args: + file_path: 업로드할 파일 경로 + url: Azure Blob Storage URL + + Returns: + bool: 업로드 성공 여부 + """ access_url = f"{url}?{SAS_TOKEN}" extension = Path(file_path).suffix.lower() content_types = { @@ -42,23 +95,27 @@ def upload_image_to_azure_blob(file_path = "스테이 머뭄.png", url = "https: ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", - ".bmp": "image/bmp" + ".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) - + headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"} + + async with aiofiles.open(file_path, "rb") as file: + file_content = await file.read() + + async with httpx.AsyncClient() as client: + response = await client.put(access_url, content=file_content, headers=headers, timeout=60.0) + if response.status_code in [200, 201]: - print(f"Success Status Code: {response.status_code}") + print(f"[upload_image_to_azure_blob] Success - Status Code: {response.status_code}") + return True else: - print(f"Failed Status Code: {response.status_code}") - print(f"Response: {response.text}") + print(f"[upload_image_to_azure_blob] Failed - Status Code: {response.status_code}") + print(f"[upload_image_to_azure_blob] Response: {response.text}") + return False - -upload_video_to_azure_blob() -upload_image_to_azure_blob() \ No newline at end of file +# 사용 예시: +# import asyncio +# asyncio.run(upload_video_to_azure_blob()) +# asyncio.run(upload_image_to_azure_blob()) diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index d12fb5a..5f5ac3d 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -1,108 +1,568 @@ """ -Video API Endpoints (Test) +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 datetime import datetime, timedelta -from typing import Optional +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_save_video +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"]) -# ============================================================================= -# Schemas -# ============================================================================= - - -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}", - summary="영상 생성 요청 (테스트)", - response_model=VideoGenerateResponse, + summary="영상 생성 요청", + description=""" +Creatomate API를 통해 영상 생성을 요청합니다. + +## 경로 파라미터 +- **task_id**: Project/Lyric/Song의 task_id (필수) - 연관된 프로젝트, 가사, 노래를 조회하는 데 사용 + +## 요청 필드 +- **template_id**: Creatomate 템플릿 ID (필수) +- **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 +{ + "template_id": "abc123...", + "image_urls": ["https://...", "https://..."], + "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) -> VideoGenerateResponse: - """영상 생성 요청 테스트 엔드포인트""" - return VideoGenerateResponse( - success=True, - task_id=task_id, - message="영상 생성 요청 성공", - error_message=None, - ) +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 호출 + 4. creatomate_render_id 업데이트 후 응답 반환 + """ + print(f"[generate_video] START - task_id: {task_id}, template_id: {request_body.template_id}") + 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}") + + # 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 호출 + print(f"[generate_video] Creatomate API generation started - task_id: {task_id}") + creatomate_service = CreatomateService() + + # 템플릿에 리소스 매핑 + modifications = creatomate_service.template_connect_resource_blackbox( + template_id=request_body.template_id, + image_url_list=request_body.image_urls, + lyric=request_body.lyrics, + music_url=request_body.music_url, + ) + print(f"[generate_video] Modifications created - task_id: {task_id}") + + # 렌더링 요청 + render_response = creatomate_service.make_creatomate_call( + template_id=request_body.template_id, + modifications=modifications, + ) + 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/{task_id}", - summary="영상 상태 조회 (테스트)", - response_model=VideoStatusResponse, + "/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(task_id: str) -> VideoStatusResponse: - """영상 상태 조회 테스트 엔드포인트""" - return VideoStatusResponse( - task_id=task_id, - status="completed", - video_url=TEST_VIDEO_URL, - ) +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 = creatomate_service.get_render_status(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: + # 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 다운로드 및 DB 업데이트 + print(f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}") + background_tasks.add_task( + download_and_save_video, + task_id=video.task_id, + video_url=video_url, + store_name=store_name, + ) + + 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="영상 다운로드 상태 조회", + 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="영상 목록 조회 (테스트)", - response_model=VideoListResponse, -) -async def get_videos() -> VideoListResponse: - """영상 목록 조회 테스트 엔드포인트""" - now = datetime.now() - videos = [ - VideoItem( - task_id=f"test-task-id-{i:03d}", - video_url=TEST_VIDEO_URL, - created_at=now - timedelta(hours=i), - ) - for i in range(10) - ] + summary="생성된 영상 목록 조회", + description=""" +완료된 영상 목록을 페이지네이션하여 조회합니다. - return VideoListResponse( - videos=videos, - total=len(videos), - ) +## 쿼리 파라미터 +- **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)}", + ) diff --git a/app/video/models.py b/app/video/models.py index 35ca01e..a164997 100644 --- a/app/video/models.py +++ b/app/video/models.py @@ -83,6 +83,12 @@ class Video(Base): comment="영상 생성 작업 고유 식별자 (UUID)", ) + creatomate_render_id: Mapped[Optional[str]] = mapped_column( + String(64), + nullable=True, + comment="Creatomate API 렌더 ID", + ) + status: Mapped[str] = mapped_column( String(50), nullable=False, diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index ec3a5e9..41d5f6e 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -1,91 +1,194 @@ -from dataclasses import dataclass, field +""" +Video API Schemas + +영상 생성 관련 Pydantic 스키마를 정의합니다. +""" + from datetime import datetime -from typing import Dict, List +from typing import Any, Dict, List, Optional -from fastapi import Request +from pydantic import BaseModel, Field -@dataclass -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 +# ============================================================================= +# Request Schemas +# ============================================================================= -@dataclass -class AttributeData: - id: int - attr_category: str - attr_value: str - created_at: datetime +class GenerateVideoRequest(BaseModel): + """영상 생성 요청 스키마 + + Usage: + POST /video/generate/{task_id} + Request body for generating a video via Creatomate API. + + Example Request: + { + "template_id": "abc123...", + "image_urls": ["https://...", "https://..."], + "lyrics": "가사 내용...", + "music_url": "https://..." + } + """ + + model_config = { + "json_schema_extra": { + "example": { + "template_id": "abc123-template-id", + "image_urls": [ + "https://example.com/image1.jpg", + "https://example.com/image2.jpg", + ], + "lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요\n군산 신흥동 말랭이 마을의 마음 힐링", + "music_url": "https://example.com/song.mp3", + } + } + } + + template_id: str = Field(..., description="Creatomate 템플릿 ID") + image_urls: List[str] = Field(..., description="영상에 사용할 이미지 URL 목록") + lyrics: str = Field(..., description="영상에 표시할 가사") + music_url: str = Field(..., description="배경 음악 URL") -@dataclass -class SongSampleData: - id: int - ai: str - ai_model: str - sample_song: str - season: str | None = None - num_of_people: int | None = None - people_category: str | None = None - genre: str | None = None +# ============================================================================= +# Response Schemas +# ============================================================================= -@dataclass -class PromptTemplateData: - id: int - prompt: str - description: str | None = None +class GenerateVideoResponse(BaseModel): + """영상 생성 응답 스키마 + + Usage: + POST /video/generate/{task_id} + Returns the task IDs for tracking video generation. + + Example Response (Success): + { + "success": true, + "task_id": "019123ab-cdef-7890-abcd-ef1234567890", + "creatomate_render_id": "render-id-123", + "message": "영상 생성 요청이 접수되었습니다.", + "error_message": null + } + """ + + success: bool = Field(..., description="요청 성공 여부") + task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)") + creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID") + message: str = Field(..., description="응답 메시지") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") -@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" +class VideoRenderData(BaseModel): + """Creatomate 렌더링 결과 데이터""" - @classmethod - async def from_form(cls, request: Request): - """Request의 form 데이터로부터 dataclass 인스턴스 생성""" - form_data = await request.form() + 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") - # 고정 필드명들 - fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"} - # lyrics-{id} 형태의 모든 키를 찾아서 ID 추출 - lyrics_ids = [] - attributes = {} +class PollingVideoResponse(BaseModel): + """영상 생성 상태 조회 응답 스키마 - 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 + Usage: + GET /video/status/{creatomate_render_id} + Creatomate API 작업 상태를 조회합니다. - # attributes를 문자열로 변환 - attributes_str = ( - "\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()]) - if attributes - else "" - ) + Note: + 상태 값: + - planned: 예약됨 + - waiting: 대기 중 + - transcribing: 트랜스크립션 중 + - rendering: 렌더링 중 + - succeeded: 성공 + - failed: 실패 - return cls( - store_name=form_data.get("store_info_name", ""), - store_id=form_data.get("store_id", ""), - attributes=attributes, - attributes_str=attributes_str, - lyrics_ids=lyrics_ids, - llm_model=form_data.get("llm_model", "gpt-4o"), - prompts=form_data.get("prompts", ""), - ) + 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="생성 일시") diff --git a/app/video/worker/video_task.py b/app/video/worker/video_task.py new file mode 100644 index 0000000..7d33134 --- /dev/null +++ b/app/video/worker/video_task.py @@ -0,0 +1,101 @@ +""" +Video Background Tasks + +영상 생성 관련 백그라운드 태스크를 정의합니다. +""" + +from datetime import date +from pathlib import Path + +import aiofiles +import httpx +from sqlalchemy import select + +from app.database.session import AsyncSessionLocal +from app.video.models import Video +from app.utils.common import generate_task_id +from config import prj_settings + + +async def download_and_save_video( + task_id: str, + video_url: str, + store_name: str, +) -> None: + """백그라운드에서 영상을 다운로드하고 Video 테이블을 업데이트합니다. + + Args: + task_id: 프로젝트 task_id + video_url: 다운로드할 영상 URL + store_name: 저장할 파일명에 사용할 업체명 + """ + print(f"[download_and_save_video] START - task_id: {task_id}, store_name: {store_name}") + try: + # 저장 경로 생성: media/{날짜}/{uuid7}/{store_name}.mp4 + today = date.today().isoformat() + unique_id = await generate_task_id() + # 파일명에 사용할 수 없는 문자 제거 + safe_store_name = "".join( + c for c in store_name if c.isalnum() or c in (" ", "_", "-") + ).strip() + safe_store_name = safe_store_name or "video" + file_name = f"{safe_store_name}.mp4" + + # 절대 경로 생성 + media_dir = Path("media") / today / unique_id + media_dir.mkdir(parents=True, exist_ok=True) + file_path = media_dir / file_name + print(f"[download_and_save_video] Directory created - path: {file_path}") + + # 영상 파일 다운로드 + print(f"[download_and_save_video] Downloading video - task_id: {task_id}, url: {video_url}") + async with httpx.AsyncClient() as client: + response = await client.get(video_url, timeout=120.0) # 영상은 더 큰 timeout + response.raise_for_status() + + async with aiofiles.open(str(file_path), "wb") as f: + await f.write(response.content) + print(f"[download_and_save_video] File saved - task_id: {task_id}, path: {file_path}") + + # 프론트엔드에서 접근 가능한 URL 생성 + relative_path = f"/media/{today}/{unique_id}/{file_name}" + base_url = f"http://{prj_settings.PROJECT_DOMAIN}" + file_url = f"{base_url}{relative_path}" + print(f"[download_and_save_video] URL generated - task_id: {task_id}, url: {file_url}") + + # Video 테이블 업데이트 (새 세션 사용) + async with AsyncSessionLocal() 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 = file_url + await session.commit() + print(f"[download_and_save_video] SUCCESS - task_id: {task_id}, status: completed") + else: + print(f"[download_and_save_video] Video NOT FOUND in DB - task_id: {task_id}") + + except Exception as e: + print(f"[download_and_save_video] EXCEPTION - task_id: {task_id}, error: {e}") + # 실패 시 Video 테이블 업데이트 + async with AsyncSessionLocal() 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_save_video] FAILED - task_id: {task_id}, status updated to failed") diff --git a/app/video/worker/video_tesk.py b/app/video/worker/video_tesk.py deleted file mode 100644 index e69de29..0000000 diff --git a/poc/crawling/creatomate/creatomate.py b/poc/creatomate/creatomate.py similarity index 100% rename from poc/crawling/creatomate/creatomate.py rename to poc/creatomate/creatomate.py diff --git a/poc/crawling/creatomate/test.py b/poc/creatomate/test.py similarity index 100% rename from poc/crawling/creatomate/test.py rename to poc/creatomate/test.py diff --git a/poc/upload_blob_as_request/upload_blob_as_request.py b/poc/upload_blob_as_request/upload_blob_as_request.py new file mode 100644 index 0000000..789c05e --- /dev/null +++ b/poc/upload_blob_as_request/upload_blob_as_request.py @@ -0,0 +1,64 @@ +import requests +from pathlib import Path + +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" + +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() \ No newline at end of file