o2o-castad-backend/app/video/worker/video_task.py
김성경 fde48e8674 Merge branch 'main' into feature-ssulbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:25:13 +09:00

409 lines
17 KiB
Python

"""
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 # 디렉토리가 비어있지 않으면 무시