174 lines
5.2 KiB
Python
174 lines
5.2 KiB
Python
"""영상 파일에서 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
|