diff --git a/music-lab/app/pipeline/video.py b/music-lab/app/pipeline/video.py index 4830e2a..e3d06f7 100644 --- a/music-lab/app/pipeline/video.py +++ b/music-lab/app/pipeline/video.py @@ -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 diff --git a/music-lab/tests/test_video_thumb.py b/music-lab/tests/test_video_thumb.py index 2361618..19ae980 100644 --- a/music-lab/tests/test_video_thumb.py +++ b/music-lab/tests/test_video_thumb.py @@ -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"