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:
@@ -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
|
||||
|
||||
@@ -3,6 +3,10 @@ import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from app.pipeline import video, thumb, storage
|
||||
|
||||
import respx
|
||||
import httpx
|
||||
from httpx import Response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_storage(monkeypatch, tmp_path):
|
||||
@@ -17,31 +21,6 @@ def tmp_storage(monkeypatch, 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")
|
||||
def test_thumb_extracts_frame(mock_run, tmp_storage):
|
||||
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):
|
||||
thumb.generate(pipeline_id=61, video_path=str(video_path),
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user