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 os
import subprocess
import logging import logging
import httpx
from . import storage from . import storage
logger = logging.getLogger("music-lab.video") 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): class VideoGenerationError(Exception):
@@ -17,46 +25,70 @@ class VideoGenerationError(Exception):
def generate(*, pipeline_id: int, audio_path: str, cover_path: str, def generate(*, pipeline_id: int, audio_path: str, cover_path: str,
genre: str, duration_sec: int, resolution: str = "1920x1080", genre: str, duration_sec: int, resolution: str = "1920x1080",
style: str = "visualizer") -> dict: style: str = "visualizer") -> dict:
"""영상 생성. 성공 시 mp4 저장 + URL 반환. 실패 시 예외.""" """원격 Windows GPU 서버 호출. 다운/실패 시 즉시 예외."""
w, h = resolution.split("x") if not ENCODER_URL:
raise VideoGenerationError(
"WINDOWS_VIDEO_ENCODER_URL 미설정 — Windows 인코더 서버 주소 필요"
)
out_path = os.path.join(storage.pipeline_dir(pipeline_id), "video.mp4") 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": payload = {
cmd = _build_visualizer_cmd(audio_path, cover_path, out_path, w, h) "cover_path_nas": nas_cover,
else: "audio_path_nas": nas_audio,
# 차후: 슬라이드쇼 등 다른 스타일 — 현재는 visualizer 폴백 "output_path_nas": nas_output,
cmd = _build_visualizer_cmd(audio_path, cover_path, out_path, w, h) "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: try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=VIDEO_TIMEOUT_S) with httpx.Client(timeout=ENCODER_TIMEOUT_S) as client:
except subprocess.TimeoutExpired: resp = client.post(f"{ENCODER_URL}/encode_video", json=payload)
raise VideoGenerationError(f"ffmpeg 타임아웃 ({VIDEO_TIMEOUT_S}s) — CPU 부하 또는 입력 파일 문제") except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.NetworkError) as e:
if result.returncode != 0: raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}")
raise VideoGenerationError(f"ffmpeg 실패: {result.stderr[-800:]}")
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 { return {
"url": storage.media_url(pipeline_id, "video.mp4"), "url": storage.media_url(pipeline_id, "video.mp4"),
"used_fallback": False, "used_fallback": False,
"duration_sec": duration_sec, "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: def _container_to_nas(container_path: str) -> str:
return [ """ /app/data/videos/3/cover.jpg → /volume1/docker/webpage/data/videos/3/cover.jpg
"ffmpeg", "-y", /app/data/abc.mp3 → /volume1/docker/webpage/data/music/abc.mp3
"-loop", "1", "-i", bg, """
"-i", audio, if container_path.startswith("/app/data/videos/"):
"-filter_complex", return container_path.replace("/app/data/videos/", NAS_VIDEOS_ROOT + "/", 1)
f"[0:v]scale={w}:{h}[bg];" if container_path.startswith("/app/data/"):
f"[1:a]showwaves=s={w}x200:mode=cline:colors=0xFF4444@0.8[wave];" rel = container_path[len("/app/data/"):]
f"[bg][wave]overlay=0:({h}-200)[out]", return NAS_MUSIC_ROOT + "/" + rel
"-map", "[out]", "-map", "1:a", return container_path
"-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,
]

View File

@@ -3,6 +3,10 @@ import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from app.pipeline import video, thumb, storage from app.pipeline import video, thumb, storage
import respx
import httpx
from httpx import Response
@pytest.fixture @pytest.fixture
def tmp_storage(monkeypatch, tmp_path): def tmp_storage(monkeypatch, tmp_path):
@@ -17,31 +21,6 @@ def tmp_storage(monkeypatch, tmp_path):
return tmp_path return tmp_path
@patch("subprocess.run")
def test_generate_video_calls_ffmpeg(mock_run, tmp_storage):
mock_run.return_value = MagicMock(returncode=0, stderr="")
out = video.generate(pipeline_id=50, audio_path=str(tmp_storage / "audio.mp3"),
cover_path=str(tmp_storage / "50" / "cover.jpg"),
genre="lo-fi", duration_sec=120, resolution="1920x1080",
style="visualizer")
assert out["url"].endswith("/50/video.mp4")
assert out["used_fallback"] is False
args = mock_run.call_args[0][0]
assert args[0] == "ffmpeg"
assert "-i" in args
assert "showwaves" in " ".join(args)
@patch("subprocess.run")
def test_generate_video_failure_marks_failed(mock_run, tmp_storage):
mock_run.return_value = MagicMock(returncode=1, stderr="bad codec")
with pytest.raises(video.VideoGenerationError):
video.generate(pipeline_id=51, audio_path=str(tmp_storage / "audio.mp3"),
cover_path=str(tmp_storage / "50" / "cover.jpg"),
genre="lo-fi", duration_sec=120, resolution="1920x1080",
style="visualizer")
@patch("subprocess.run") @patch("subprocess.run")
def test_thumb_extracts_frame(mock_run, tmp_storage): def test_thumb_extracts_frame(mock_run, tmp_storage):
mock_run.return_value = MagicMock(returncode=0, stderr="") mock_run.return_value = MagicMock(returncode=0, stderr="")
@@ -64,3 +43,90 @@ def test_thumb_failure_raises(mock_run, tmp_storage):
with pytest.raises(thumb.ThumbGenerationError): with pytest.raises(thumb.ThumbGenerationError):
thumb.generate(pipeline_id=61, video_path=str(video_path), thumb.generate(pipeline_id=61, video_path=str(video_path),
track_title="X", overlay_text=False) track_title="X", overlay_text=False)
# ===== Video tests (replacing the FFmpeg-based tests) =====
@pytest.fixture
def encoder_env(monkeypatch):
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
monkeypatch.setattr(video, "NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
monkeypatch.setattr(video, "NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
@respx.mock
def test_generate_video_calls_remote_encoder(encoder_env, tmp_path, monkeypatch):
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
respx.post("http://192.168.45.59:8765/encode_video").mock(
return_value=Response(200, json={
"ok": True, "duration_ms": 12000,
"output_path_nas": "/volume1/docker/webpage/data/videos/3/video.mp4",
"output_bytes": 28000000,
"encoder": "h264_nvenc", "preset": "p4",
})
)
out = video.generate(
pipeline_id=3,
audio_path="/app/data/1c695df3.mp3",
cover_path="/app/data/videos/3/cover.jpg",
genre="lo-fi", duration_sec=120, resolution="1920x1080",
style="visualizer",
)
assert out["url"].endswith("/3/video.mp4")
assert out["used_fallback"] is False
assert out["encode_duration_ms"] == 12000
@respx.mock
def test_generate_video_raises_on_connection_error(encoder_env, monkeypatch, tmp_path):
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
respx.post("http://192.168.45.59:8765/encode_video").mock(
side_effect=httpx.ConnectError("Connection refused")
)
with pytest.raises(video.VideoGenerationError) as exc:
video.generate(
pipeline_id=4,
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/4/cover.jpg",
genre="lo-fi", duration_sec=120, resolution="1920x1080",
)
assert "연결 실패" in str(exc.value) or "Connection" in str(exc.value)
@respx.mock
def test_generate_video_raises_on_500(encoder_env, monkeypatch, tmp_path):
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
respx.post("http://192.168.45.59:8765/encode_video").mock(
return_value=Response(500, json={"detail": {"ok": False, "stage": "ffmpeg", "error": "bad codec"}})
)
with pytest.raises(video.VideoGenerationError) as exc:
video.generate(
pipeline_id=5,
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/5/cover.jpg",
genre="lo-fi", duration_sec=120, resolution="1920x1080",
)
assert "Windows 인코더 오류" in str(exc.value)
assert "ffmpeg" in str(exc.value)
def test_generate_video_no_url_configured(monkeypatch, tmp_path):
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
monkeypatch.setattr(video, "ENCODER_URL", "")
with pytest.raises(video.VideoGenerationError) as exc:
video.generate(
pipeline_id=6,
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/6/cover.jpg",
genre="lo-fi", duration_sec=120, resolution="1920x1080",
)
assert "WINDOWS_VIDEO_ENCODER_URL" in str(exc.value)
def test_container_to_nas_videos_path(monkeypatch):
monkeypatch.setattr(video, "NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
monkeypatch.setattr(video, "NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
assert video._container_to_nas("/app/data/videos/3/cover.jpg") == "/volume1/docker/webpage/data/videos/3/cover.jpg"
def test_container_to_nas_music_path(monkeypatch):
monkeypatch.setattr(video, "NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
monkeypatch.setattr(video, "NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
assert video._container_to_nas("/app/data/abc.mp3") == "/volume1/docker/webpage/data/music/abc.mp3"