- pipeline/video.py 재작성: subprocess.run 제거, httpx로 Windows /encode_video 호출 - Windows 서버 다운 시 즉시 VideoGenerationError (NAS 로컬 폴백 X — 의도적 결정) - /app/data/* → /volume1/docker/webpage/data/* 경로 변환 (_container_to_nas) - 테스트는 respx mock 기반으로 교체 (6개)
95 lines
3.5 KiB
Python
95 lines
3.5 KiB
Python
"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출.
|
|
|
|
Windows 서버 다운/실패 시 즉시 예외 (NAS 로컬 폴백 없음 — 의도적 결정).
|
|
"""
|
|
import os
|
|
import logging
|
|
import httpx
|
|
|
|
from . import storage
|
|
|
|
logger = logging.getLogger("music-lab.video")
|
|
|
|
ENCODER_URL = os.getenv("WINDOWS_VIDEO_ENCODER_URL", "")
|
|
ENCODER_TIMEOUT_S = 200 # Windows 서버 ffmpeg 180s + 마진
|
|
|
|
# NAS 호스트 절대경로 prefix — docker bind mount의 host 측
|
|
NAS_VIDEOS_ROOT = os.getenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
|
NAS_MUSIC_ROOT = os.getenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
|
|
|
|
|
|
class VideoGenerationError(Exception):
|
|
pass
|
|
|
|
|
|
def generate(*, pipeline_id: int, audio_path: str, cover_path: str,
|
|
genre: str, duration_sec: int, resolution: str = "1920x1080",
|
|
style: str = "visualizer") -> dict:
|
|
"""원격 Windows GPU 서버 호출. 다운/실패 시 즉시 예외."""
|
|
if not ENCODER_URL:
|
|
raise VideoGenerationError(
|
|
"WINDOWS_VIDEO_ENCODER_URL 미설정 — Windows 인코더 서버 주소 필요"
|
|
)
|
|
|
|
out_path = os.path.join(storage.pipeline_dir(pipeline_id), "video.mp4")
|
|
nas_audio = _container_to_nas(audio_path)
|
|
nas_cover = _container_to_nas(cover_path)
|
|
nas_output = _container_to_nas(out_path)
|
|
|
|
payload = {
|
|
"cover_path_nas": nas_cover,
|
|
"audio_path_nas": nas_audio,
|
|
"output_path_nas": nas_output,
|
|
"resolution": resolution,
|
|
"duration_sec": duration_sec,
|
|
"style": style,
|
|
}
|
|
|
|
logger.info("Windows 인코더 호출: pipeline=%d audio=%s", pipeline_id, audio_path)
|
|
try:
|
|
with httpx.Client(timeout=ENCODER_TIMEOUT_S) as client:
|
|
resp = client.post(f"{ENCODER_URL}/encode_video", json=payload)
|
|
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.NetworkError) as e:
|
|
raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}")
|
|
|
|
if resp.status_code != 200:
|
|
try:
|
|
body = resp.json()
|
|
# FastAPI HTTPException wraps in {"detail": ...}
|
|
detail = body.get("detail", body) if isinstance(body, dict) else body
|
|
except Exception:
|
|
detail = {"error": resp.text[:300]}
|
|
if isinstance(detail, dict):
|
|
stage = detail.get("stage", "?")
|
|
error = detail.get("error", str(detail))
|
|
else:
|
|
stage = "?"
|
|
error = str(detail)
|
|
raise VideoGenerationError(
|
|
f"Windows 인코더 오류 ({resp.status_code}): {stage} — {error}"
|
|
)
|
|
|
|
data = resp.json()
|
|
if not data.get("ok"):
|
|
raise VideoGenerationError(f"Windows 인코더 응답 ok=false: {data}")
|
|
|
|
return {
|
|
"url": storage.media_url(pipeline_id, "video.mp4"),
|
|
"used_fallback": False,
|
|
"duration_sec": duration_sec,
|
|
"encode_duration_ms": data.get("duration_ms"),
|
|
"encoder": data.get("encoder", "h264_nvenc"),
|
|
}
|
|
|
|
|
|
def _container_to_nas(container_path: str) -> str:
|
|
""" /app/data/videos/3/cover.jpg → /volume1/docker/webpage/data/videos/3/cover.jpg
|
|
/app/data/abc.mp3 → /volume1/docker/webpage/data/music/abc.mp3
|
|
"""
|
|
if container_path.startswith("/app/data/videos/"):
|
|
return container_path.replace("/app/data/videos/", NAS_VIDEOS_ROOT + "/", 1)
|
|
if container_path.startswith("/app/data/"):
|
|
rel = container_path[len("/app/data/"):]
|
|
return NAS_MUSIC_ROOT + "/" + rel
|
|
return container_path
|