""" Video Background Tasks 영상 생성 관련 백그라운드 태스크를 정의합니다. """ from pathlib import Path import aiofiles import httpx from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from app.credit.services.credit_service import refund_credit_for_job from app.database.session import BackgroundSessionLocal from app.ssulbox.constants import JOB_TYPE_VIDEO as CREDIT_JOB_TYPE_VIDEO from app.video.models import Video from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.logger import get_logger from app.utils.video_poster import generate_and_store_poster # 로거 설정 logger = get_logger("video") # HTTP 요청 설정 REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분) #: 영상 1편 생성에 차감/환불할 크레딧 (generate_video 의 VIDEO_CREDIT_COST 와 동일해야 함) VIDEO_CREDIT_COST = 1 async def _fail_and_refund( task_id: str | None, creatomate_render_id: str | None = None, user_uuid: str | None = None, *, reason: str, ) -> None: """영상 실패 처리 + 선차감한 크레딧 환불. 크레딧은 generate_video 에서 선차감되므로 실패 시 돌려줘야 한다. refund_credit_for_job 은 (job_type, job_ref) 기준 멱등이라 실패 콜백이 여러 번 와도 환불은 한 번만 일어난다. 차감 기록이 없으면(정책 전환 이전에 시작된 in-flight 영상) 아무것도 하지 않는다. Args: task_id: 프로젝트 task_id (크레딧 멱등 키이기도 하다) creatomate_render_id: Creatomate 렌더 ID user_uuid: 환불 대상 사용자. None 이면 환불을 건너뛴다 reason: 원장에 남길 사유 """ if task_id: await _update_video_status( task_id, "failed", creatomate_render_id=creatomate_render_id ) if not task_id or not user_uuid: return try: async with BackgroundSessionLocal() as session: await refund_credit_for_job( session=session, user_uuid=user_uuid, amount=VIDEO_CREDIT_COST, job_type=CREDIT_JOB_TYPE_VIDEO, job_ref=task_id, reason=reason, ) await session.commit() except Exception as e: # 환불 실패로 실패 처리 자체가 막히면 안 된다. 로그를 남기고 넘어간다. logger.error( f"[_fail_and_refund] REFUND FAILED - task_id: {task_id}, " f"user_uuid: {user_uuid}, error: {e}", exc_info=True, ) async def _update_video_status( task_id: str, status: str, video_url: str | None = None, creatomate_render_id: str | None = None, poster_url: str | None = None, ) -> int | None: """Video 테이블의 상태를 업데이트합니다. Args: task_id: 프로젝트 task_id status: 변경할 상태 ("processing", "completed", "failed") video_url: 영상 URL creatomate_render_id: Creatomate render ID (선택) poster_url: 영상 첫 프레임 포스터 URL (선택) Returns: int | None: 업데이트된 Video id. 대상이 없거나 실패하면 None. """ try: async with BackgroundSessionLocal() as session: if creatomate_render_id: query_result = await session.execute( select(Video) .where(Video.creatomate_render_id == creatomate_render_id) .order_by(Video.created_at.desc()) .limit(1) ) else: query_result = await session.execute( select(Video) .where(Video.task_id == task_id) .order_by(Video.created_at.desc()) .limit(1) ) video = query_result.scalar_one_or_none() if video: video.status = status if video_url is not None: video.result_movie_url = video_url if poster_url is not None: video.poster_url = poster_url await session.commit() logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}") return video.id else: logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}") return None except SQLAlchemyError as e: logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}") return None except Exception as e: logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}") return None async def _try_generate_sns_metadata(video_id: int) -> None: """SEO 생성 실패가 영상 완료 처리에 영향을 주지 않도록 격리합니다.""" from app.social.services.seo_service import seo_service try: async with BackgroundSessionLocal() as session: await seo_service.generate_and_save_for_video(video_id, session) await session.commit() except Exception as e: logger.warning( f"[VideoSEO] Failed to generate SNS metadata - video_id: {video_id}, error: {e}", exc_info=True, ) async def _try_generate_poster( temp_file_path: Path, user_uuid: str, task_id: str, render_id: str, ) -> str | None: """포스터 생성 실패가 영상 생성 완료 처리에 영향을 주지 않도록 격리합니다.""" try: return await generate_and_store_poster( video_path=temp_file_path, user_uuid=user_uuid, task_id=task_id, file_stem=render_id, ) except Exception as e: logger.warning( f"[VideoPoster] Failed to generate poster - task_id: {task_id}, render_id: {render_id}, error: {e}", exc_info=True, ) return None async def _download_video(url: str, task_id: str) -> bytes: """URL에서 영상을 다운로드합니다. Args: url: 다운로드할 URL task_id: 로그용 task_id Returns: bytes: 다운로드한 파일 내용 Raises: httpx.HTTPError: 다운로드 실패 시 """ logger.info(f"[VideoDownload] Downloading - task_id: {task_id}") async with httpx.AsyncClient() as client: response = await client.get(url, timeout=REQUEST_TIMEOUT) response.raise_for_status() logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") return response.content async def download_and_upload_video_to_blob( task_id: str, video_url: str, creatomate_render_id: str, user_uuid: str, ) -> None: """백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. 파일명은 creatomate_render_id를 사용하여 고유성을 보장합니다. Args: task_id: 프로젝트 task_id video_url: 다운로드할 영상 URL creatomate_render_id: Creatomate API 렌더 ID (파일명 및 Video 식별용) user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용) """ logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}") temp_file_path: Path | None = None try: # creatomate_render_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요) file_name = f"{creatomate_render_id}.mp4" # 임시 저장 경로 생성 temp_dir = Path("media") / "temp" / task_id temp_dir.mkdir(parents=True, exist_ok=True) temp_file_path = temp_dir / file_name logger.debug(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}") # 영상 파일 다운로드 logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}") content = await _download_video(video_url, task_id) async with aiofiles.open(str(temp_file_path), "wb") as f: await f.write(content) logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") # Azure Blob Storage에 업로드 uploader = AzureBlobUploader(user_uuid=user_uuid, 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 logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") poster_url = await _try_generate_poster( temp_file_path, user_uuid, task_id, creatomate_render_id ) # Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별) video_id = await _update_video_status( task_id, "completed", blob_url, creatomate_render_id, poster_url=poster_url, ) if video_id is not None: await _try_generate_sns_metadata(video_id) # 크레딧은 generate_video 에서 이미 선차감했다. 여기서 차감하지 않는다. logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}") except httpx.HTTPError as e: logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True) await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 다운로드 실패") except SQLAlchemyError as e: logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True) await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 저장 실패") except Exception as e: logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True) await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 처리 실패") finally: # 임시 파일 삭제 if temp_file_path and temp_file_path.exists(): try: temp_file_path.unlink() logger.debug(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}") except Exception as e: logger.warning(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, user_uuid: str, ) -> None: """creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. 파일명은 creatomate_render_id를 사용하여 고유성을 보장합니다. Args: creatomate_render_id: Creatomate API 렌더 ID (파일명으로도 사용) video_url: 다운로드할 영상 URL user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용) """ logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}") 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: logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}") return task_id = video.task_id logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}") # creatomate_render_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요) file_name = f"{creatomate_render_id}.mp4" # 임시 저장 경로 생성 temp_dir = Path("media") / "temp" / task_id temp_dir.mkdir(parents=True, exist_ok=True) temp_file_path = temp_dir / file_name logger.debug(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}") # 영상 파일 다운로드 logger.info(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}") content = await _download_video(video_url, task_id) async with aiofiles.open(str(temp_file_path), "wb") as f: await f.write(content) logger.info(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(user_uuid=user_uuid, 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 logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}") poster_url = await _try_generate_poster( temp_file_path, user_uuid, task_id, creatomate_render_id ) # Video 테이블 업데이트 video_id = await _update_video_status( task_id=task_id, status="completed", video_url=blob_url, creatomate_render_id=creatomate_render_id, poster_url=poster_url, ) if video_id is not None: await _try_generate_sns_metadata(video_id) logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}") except httpx.HTTPError as e: logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 다운로드 실패") except SQLAlchemyError as e: logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 저장 실패") except Exception as e: logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 처리 실패") finally: # 임시 파일 삭제 if temp_file_path and temp_file_path.exists(): try: temp_file_path.unlink() logger.debug(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}") except Exception as e: logger.warning(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 # 디렉토리가 비어있지 않으면 무시