feat(music-lab): 영상 인코딩을 Windows GPU 서버로 오프로드

- 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개)
This commit is contained in:
2026-05-09 02:01:34 +09:00
parent bb0b0dff25
commit 240bd38541
2 changed files with 157 additions and 59 deletions

View File

@@ -1,13 +1,21 @@
"""영상 비주얼 생성 — visualizer/슬라이드쇼 스타일."""
"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출.
Windows 서버 다운/실패 시 즉시 예외 (NAS 로컬 폴백 없음 — 의도적 결정).
"""
import os
import subprocess
import logging
import httpx
from . import storage
logger = logging.getLogger("music-lab.video")
VIDEO_TIMEOUT_S = 600 # 10분 — Celeron J4025 같은 저성능 CPU 여유
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):
@@ -17,46 +25,70 @@ class VideoGenerationError(Exception):
def generate(*, pipeline_id: int, audio_path: str, cover_path: str,
genre: str, duration_sec: int, resolution: str = "1920x1080",
style: str = "visualizer") -> dict:
"""영상 생성. 성공 시 mp4 저장 + URL 반환. 실패 시 예외."""
w, h = resolution.split("x")
"""원격 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)
if style == "visualizer":
cmd = _build_visualizer_cmd(audio_path, cover_path, out_path, w, h)
else:
# 차후: 슬라이드쇼 등 다른 스타일 — 현재는 visualizer 폴백
cmd = _build_visualizer_cmd(audio_path, cover_path, out_path, w, h)
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("ffmpeg 실행: %s", " ".join(cmd))
logger.info("Windows 인코더 호출: pipeline=%d audio=%s", pipeline_id, audio_path)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=VIDEO_TIMEOUT_S)
except subprocess.TimeoutExpired:
raise VideoGenerationError(f"ffmpeg 타임아웃 ({VIDEO_TIMEOUT_S}s) — CPU 부하 또는 입력 파일 문제")
if result.returncode != 0:
raise VideoGenerationError(f"ffmpeg 실패: {result.stderr[-800:]}")
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 _build_visualizer_cmd(audio: str, bg: str, out: str, w: str, h: str) -> list:
return [
"ffmpeg", "-y",
"-loop", "1", "-i", bg,
"-i", audio,
"-filter_complex",
f"[0:v]scale={w}:{h}[bg];"
f"[1:a]showwaves=s={w}x200:mode=cline:colors=0xFF4444@0.8[wave];"
f"[bg][wave]overlay=0:({h}-200)[out]",
"-map", "[out]", "-map", "1:a",
"-c:v", "libx264",
"-preset", "ultrafast", # was "fast" — ultrafast is 5-10x faster on weak CPUs
"-tune", "stillimage", # 정적 배경 + 파형 오버레이는 stillimage 튜닝이 적합
"-threads", "0", # use all available CPU cores
"-crf", "23",
"-c:a", "aac", "-b:a", "192k",
"-shortest", out,
]
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