"""영상 파일에서 SNS 공유용 포스터 이미지를 생성하고 저장합니다.""" import asyncio from pathlib import Path from app.utils.logger import get_logger from app.utils.upload_blob_as_request import AzureBlobUploader logger = get_logger("video_poster") FFMPEG_TIMEOUT_SECONDS = 30.0 FFMPEG_CLEANUP_TIMEOUT_SECONDS = 5.0 _STDERR_LOG_LIMIT = 500 async def _kill_and_wait(process: asyncio.subprocess.Process) -> None: """실행 중인 ffmpeg 프로세스를 종료하고 자원을 회수합니다.""" try: process.kill() except ProcessLookupError: pass except Exception as exc: logger.warning( "[video_poster] ffmpeg 프로세스 종료에 실패했습니다: %s", exc, ) try: await asyncio.wait_for( process.wait(), timeout=FFMPEG_CLEANUP_TIMEOUT_SECONDS, ) except TimeoutError: logger.warning( "[video_poster] 종료한 ffmpeg 프로세스 회수 시간이 초과되었습니다 " "(timeout=%ss)", FFMPEG_CLEANUP_TIMEOUT_SECONDS, ) except Exception as exc: logger.warning( "[video_poster] ffmpeg 프로세스 회수에 실패했습니다: %s", exc, ) def _format_stderr(stderr: bytes) -> str: """ffmpeg 표준 오류를 로그에 안전한 길이의 문자열로 변환합니다.""" return stderr.decode("utf-8", errors="replace").strip()[-_STDERR_LOG_LIMIT:] async def extract_first_frame(video_path: str | Path) -> bytes | None: """로컬 영상의 첫 프레임을 JPEG 바이트로 추출하고 실패 시 ``None``을 반환합니다.""" process: asyncio.subprocess.Process | None = None try: process = await asyncio.create_subprocess_exec( "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-i", str(video_path), "-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "pipe:1", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: stdout, stderr = await asyncio.wait_for( process.communicate(), timeout=FFMPEG_TIMEOUT_SECONDS, ) except TimeoutError: await _kill_and_wait(process) logger.warning( "[video_poster] ffmpeg 첫 프레임 추출 시간이 초과되었습니다 " "(path=%s, timeout=%ss)", video_path, FFMPEG_TIMEOUT_SECONDS, ) return None if process.returncode != 0: logger.warning( "[video_poster] ffmpeg 첫 프레임 추출에 실패했습니다 " "(path=%s, returncode=%s, stderr=%s)", video_path, process.returncode, _format_stderr(stderr), ) return None if not stdout: logger.warning( "[video_poster] ffmpeg가 빈 이미지를 반환했습니다 (path=%s)", video_path, ) return None return stdout except asyncio.CancelledError: if process is not None: await _kill_and_wait(process) raise except Exception as exc: if process is not None: await _kill_and_wait(process) logger.warning( "[video_poster] 첫 프레임 추출 중 오류가 발생했습니다 " "(path=%s, error=%s: %s)", video_path, type(exc).__name__, exc, ) return None async def generate_and_store_poster( *, video_path: str | Path, user_uuid: str, task_id: str, file_stem: str, ) -> str | None: """첫 프레임을 Blob에 저장하고 공개 URL을 반환하며, 실패 시 ``None``을 반환합니다.""" try: image_bytes = await extract_first_frame(video_path) if image_bytes is None: return None uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id) uploaded = await uploader.upload_image_bytes( image_bytes, f"{file_stem}.jpg", ) if not uploaded: logger.warning( "[video_poster] 포스터 Blob 업로드에 실패했습니다 " "(path=%s, task_id=%s)", video_path, task_id, ) return None if not uploader.public_url: logger.warning( "[video_poster] 포스터 업로드 후 공개 URL이 비어 있습니다 " "(path=%s, task_id=%s)", video_path, task_id, ) return None return uploader.public_url except Exception as exc: logger.warning( "[video_poster] 포스터 생성 또는 저장 중 오류가 발생했습니다 " "(path=%s, task_id=%s, error=%s: %s)", video_path, task_id, type(exc).__name__, exc, ) return None