docs: GPU 영상 인코딩 오프로드 spec + plan
NAS 저성능 CPU(J4025) ffmpeg 5분 타임아웃 → Windows PC RTX 5070 Ti NVENC로 오프로드. 같은 music_ai 서버에 /encode_video endpoint 추가, NAS는 다운 시 즉시 실패 (로컬 폴백 X). LAN 무인증. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
737
docs/superpowers/plans/2026-05-09-gpu-video-offload.md
Normal file
737
docs/superpowers/plans/2026-05-09-gpu-video-offload.md
Normal file
@@ -0,0 +1,737 @@
|
||||
# GPU 영상 인코딩 오프로드 — 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development.
|
||||
|
||||
**Goal:** NAS의 ffmpeg 영상 인코딩을 Windows PC(RTX 5070 Ti) NVENC로 오프로드.
|
||||
|
||||
**Architecture:** music-lab(NAS) → HTTP POST → music_ai(Windows, port 8765 `/encode_video`) → ffmpeg NVENC → SMB로 NAS에 직접 mp4 저장. Windows 서버 다운 시 NAS는 즉시 실패.
|
||||
|
||||
**Tech Stack:** httpx (NAS 측 HTTP 클라이언트), FastAPI (Windows 서버 endpoint), ffmpeg.exe with NVENC.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 경로 | 책임 |
|
||||
|------|------|
|
||||
| `music_ai/video_encoder.py` (new) | 경로 변환 + ffmpeg NVENC subprocess 호출 + 검증 |
|
||||
| `music_ai/server.py` (modify) | `/encode_video` POST endpoint 등록, `/health`에 ffmpeg/nvenc 정보 추가 |
|
||||
| `music_ai/.env.example` (modify) | NAS_VOLUME_PREFIX, WINDOWS_DRIVE_ROOT, FFMPEG_PATH 문서화 |
|
||||
| `music_ai/tests/test_video_encoder.py` (new) | translate_path, encode endpoint 단위 테스트 |
|
||||
| `music-lab/app/pipeline/video.py` (rewrite) | subprocess 제거, httpx로 Windows 서버 호출 |
|
||||
| `music-lab/tests/test_video_thumb.py` (rewrite video tests) | respx mock 기반 |
|
||||
| `web-backend/docker-compose.yml` (modify) | music-lab env 3개 추가 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Windows `music_ai/video_encoder.py` + 테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `music_ai/video_encoder.py`
|
||||
- Create: `music_ai/tests/test_video_encoder.py`
|
||||
|
||||
### Step 1: Write failing test
|
||||
|
||||
```python
|
||||
# music_ai/tests/test_video_encoder.py
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from video_encoder import translate_path, encode_video, EncodeError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch):
|
||||
monkeypatch.setenv("NAS_VOLUME_PREFIX", "/volume1/")
|
||||
monkeypatch.setenv("WINDOWS_DRIVE_ROOT", "Z:\\")
|
||||
monkeypatch.setenv("FFMPEG_PATH", "C:\\ffmpeg\\bin\\ffmpeg.exe")
|
||||
|
||||
|
||||
def test_translate_path_basic(env):
|
||||
assert translate_path("/volume1/docker/webpage/data/x.jpg") == r"Z:\docker\webpage\data\x.jpg"
|
||||
|
||||
|
||||
def test_translate_path_nested(env):
|
||||
assert translate_path("/volume1/docker/webpage/data/videos/3/cover.jpg") == r"Z:\docker\webpage\data\videos\3\cover.jpg"
|
||||
|
||||
|
||||
def test_translate_path_rejects_bad_prefix(env):
|
||||
with pytest.raises(ValueError):
|
||||
translate_path("/etc/passwd")
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_video_success(mock_run, env, tmp_path):
|
||||
# 입력 파일 fake
|
||||
cover = tmp_path / "cover.jpg"
|
||||
cover.write_bytes(b"\x00" * 100)
|
||||
audio = tmp_path / "audio.mp3"
|
||||
audio.write_bytes(b"\x00" * 100)
|
||||
out = tmp_path / "video.mp4"
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
# ffmpeg 실행을 흉내내어 출력 파일을 만듦
|
||||
out.write_bytes(b"\x00" * (2 * 1024 * 1024)) # 2MB
|
||||
return MagicMock(returncode=0, stderr="")
|
||||
mock_run.side_effect = fake_run
|
||||
|
||||
# translate_path를 mock해서 입력 경로를 직접 사용
|
||||
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
|
||||
result = encode_video(
|
||||
cover_path_nas="/volume1/cover.jpg",
|
||||
audio_path_nas="/volume1/audio.mp3",
|
||||
output_path_nas="/volume1/video.mp4",
|
||||
resolution="1920x1080",
|
||||
duration_sec=120,
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["encoder"] == "h264_nvenc"
|
||||
assert result["output_bytes"] > 1024 * 1024
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_video_input_missing(mock_run, env, tmp_path):
|
||||
with pytest.raises(EncodeError) as exc:
|
||||
encode_video(
|
||||
cover_path_nas="/volume1/missing.jpg",
|
||||
audio_path_nas="/volume1/missing.mp3",
|
||||
output_path_nas="/volume1/out.mp4",
|
||||
resolution="1920x1080",
|
||||
duration_sec=120,
|
||||
)
|
||||
assert "input_validation" in str(exc.value)
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_video_ffmpeg_failure(mock_run, env, tmp_path):
|
||||
cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
|
||||
audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="invalid codec\n" * 50)
|
||||
|
||||
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
|
||||
with pytest.raises(EncodeError) as exc:
|
||||
encode_video(
|
||||
cover_path_nas="/volume1/cover.jpg",
|
||||
audio_path_nas="/volume1/audio.mp3",
|
||||
output_path_nas="/volume1/out.mp4",
|
||||
resolution="1920x1080",
|
||||
duration_sec=120,
|
||||
)
|
||||
assert "ffmpeg" in str(exc.value).lower()
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_video_output_too_small(mock_run, env, tmp_path):
|
||||
cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
|
||||
audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
|
||||
def fake_run(cmd, **kwargs):
|
||||
(tmp_path / "out.mp4").write_bytes(b"\x00" * 100) # 100 bytes — too small
|
||||
return MagicMock(returncode=0, stderr="")
|
||||
mock_run.side_effect = fake_run
|
||||
|
||||
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
|
||||
with pytest.raises(EncodeError) as exc:
|
||||
encode_video(
|
||||
cover_path_nas="/volume1/cover.jpg",
|
||||
audio_path_nas="/volume1/audio.mp3",
|
||||
output_path_nas="/volume1/out.mp4",
|
||||
resolution="1920x1080",
|
||||
duration_sec=120,
|
||||
)
|
||||
assert "output_check" in str(exc.value)
|
||||
|
||||
|
||||
def test_resolution_validation(env):
|
||||
with pytest.raises(EncodeError) as exc:
|
||||
encode_video(
|
||||
cover_path_nas="/volume1/x.jpg",
|
||||
audio_path_nas="/volume1/x.mp3",
|
||||
output_path_nas="/volume1/out.mp4",
|
||||
resolution="invalid",
|
||||
duration_sec=120,
|
||||
)
|
||||
assert "resolution" in str(exc.value).lower()
|
||||
```
|
||||
|
||||
### Step 2: Run test to verify it fails
|
||||
|
||||
```bash
|
||||
cd music_ai && python -m pytest tests/test_video_encoder.py -v
|
||||
```
|
||||
|
||||
Expected: ImportError on `video_encoder` module.
|
||||
|
||||
### Step 3: Implement `video_encoder.py`
|
||||
|
||||
```python
|
||||
"""GPU(NVENC) 영상 인코더 — NAS music-lab에서 호출."""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("music_ai.video_encoder")
|
||||
|
||||
NAS_VOLUME_PREFIX = os.getenv("NAS_VOLUME_PREFIX", "/volume1/")
|
||||
WINDOWS_DRIVE_ROOT = os.getenv("WINDOWS_DRIVE_ROOT", "Z:\\")
|
||||
FFMPEG_PATH = os.getenv("FFMPEG_PATH", "ffmpeg")
|
||||
FFMPEG_TIMEOUT_S = 180
|
||||
RESOLUTION_RE = re.compile(r"^\d{3,4}x\d{3,4}$")
|
||||
MIN_OUTPUT_BYTES = 1024 * 1024 # 1MB
|
||||
|
||||
|
||||
class EncodeError(Exception):
|
||||
"""{stage: input_validation|path_translate|ffmpeg|output_check, message: ...}"""
|
||||
def __init__(self, stage: str, message: str):
|
||||
self.stage = stage
|
||||
self.message = message
|
||||
super().__init__(f"[{stage}] {message}")
|
||||
|
||||
|
||||
def translate_path(nas_path: str) -> str:
|
||||
"""NAS 절대경로 → Windows SMB 경로."""
|
||||
if not nas_path.startswith(NAS_VOLUME_PREFIX):
|
||||
raise ValueError(f"NAS prefix 불일치: {nas_path}")
|
||||
rel = nas_path[len(NAS_VOLUME_PREFIX):]
|
||||
return WINDOWS_DRIVE_ROOT + rel.replace("/", "\\")
|
||||
|
||||
|
||||
def encode_video(*, cover_path_nas: str, audio_path_nas: str,
|
||||
output_path_nas: str, resolution: str,
|
||||
duration_sec: int = 0, style: str = "visualizer") -> dict:
|
||||
"""영상 인코딩 + Z:\\에 직접 저장."""
|
||||
# 1) Resolution 검증
|
||||
if not RESOLUTION_RE.match(resolution):
|
||||
raise EncodeError("input_validation", f"invalid resolution: {resolution}")
|
||||
w, h = resolution.split("x")
|
||||
|
||||
# 2) 경로 변환
|
||||
try:
|
||||
cover_win = translate_path(cover_path_nas)
|
||||
audio_win = translate_path(audio_path_nas)
|
||||
out_win = translate_path(output_path_nas)
|
||||
except ValueError as e:
|
||||
raise EncodeError("path_translate", str(e))
|
||||
|
||||
# 3) 입력 존재 확인
|
||||
if not os.path.isfile(cover_win):
|
||||
raise EncodeError("input_validation", f"cover not found: {cover_win}")
|
||||
if not os.path.isfile(audio_win):
|
||||
raise EncodeError("input_validation", f"audio not found: {audio_win}")
|
||||
|
||||
# 4) 출력 디렉토리 보장
|
||||
os.makedirs(os.path.dirname(out_win), exist_ok=True)
|
||||
|
||||
# 5) ffmpeg 명령
|
||||
cmd = [
|
||||
FFMPEG_PATH, "-y",
|
||||
"-hwaccel", "cuda",
|
||||
"-loop", "1", "-i", cover_win,
|
||||
"-i", audio_win,
|
||||
"-filter_complex",
|
||||
f"[0:v]scale={w}:{h},format=yuv420p[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", "h264_nvenc",
|
||||
"-preset", "p4",
|
||||
"-rc", "vbr",
|
||||
"-cq", "23",
|
||||
"-b:v", "0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-shortest", out_win,
|
||||
]
|
||||
logger.info("ffmpeg: %s", " ".join(cmd))
|
||||
|
||||
# 6) ffmpeg 실행
|
||||
import time
|
||||
t0 = time.time()
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=FFMPEG_TIMEOUT_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise EncodeError("ffmpeg", f"timeout after {FFMPEG_TIMEOUT_S}s")
|
||||
duration_ms = int((time.time() - t0) * 1000)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise EncodeError("ffmpeg", f"returncode={result.returncode}: {result.stderr[-800:]}")
|
||||
|
||||
# 7) 출력 검증
|
||||
if not os.path.isfile(out_win):
|
||||
raise EncodeError("output_check", "output file not created")
|
||||
output_bytes = os.path.getsize(out_win)
|
||||
if output_bytes < MIN_OUTPUT_BYTES:
|
||||
raise EncodeError("output_check", f"output too small: {output_bytes} bytes")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"duration_ms": duration_ms,
|
||||
"output_path_nas": output_path_nas,
|
||||
"output_bytes": output_bytes,
|
||||
"encoder": "h264_nvenc",
|
||||
"preset": "p4",
|
||||
}
|
||||
|
||||
|
||||
def check_ffmpeg_nvenc() -> bool:
|
||||
"""서버 시작 시 NVENC 가용성 확인."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[FFMPEG_PATH, "-encoders"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return "h264_nvenc" in result.stdout
|
||||
except Exception:
|
||||
return False
|
||||
```
|
||||
|
||||
### Step 4: Run tests
|
||||
|
||||
```bash
|
||||
cd music_ai && python -m pytest tests/test_video_encoder.py -v
|
||||
```
|
||||
|
||||
Expected: 6 PASS
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/music_ai
|
||||
git init 2>/dev/null || true # may not be a git repo, that's OK
|
||||
# music_ai is local-only per CLAUDE.md, no remote push
|
||||
```
|
||||
|
||||
(music_ai is local-only; just save the file. No git push needed.)
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Windows `music_ai/server.py` — `/encode_video` endpoint + 헬스 확장
|
||||
|
||||
**Files:**
|
||||
- Modify: `music_ai/server.py`
|
||||
- Modify: `music_ai/.env.example`
|
||||
|
||||
### Step 1: Read existing server.py to understand FastAPI pattern + existing /health
|
||||
|
||||
### Step 2: Add `/encode_video` endpoint
|
||||
|
||||
```python
|
||||
# server.py — 추가
|
||||
from pydantic import BaseModel
|
||||
from fastapi import HTTPException
|
||||
import video_encoder
|
||||
|
||||
|
||||
class EncodeVideoRequest(BaseModel):
|
||||
cover_path_nas: str
|
||||
audio_path_nas: str
|
||||
output_path_nas: str
|
||||
resolution: str = "1920x1080"
|
||||
duration_sec: int = 0
|
||||
style: str = "visualizer"
|
||||
|
||||
|
||||
@app.post("/encode_video")
|
||||
def encode_video_endpoint(req: EncodeVideoRequest):
|
||||
try:
|
||||
result = video_encoder.encode_video(
|
||||
cover_path_nas=req.cover_path_nas,
|
||||
audio_path_nas=req.audio_path_nas,
|
||||
output_path_nas=req.output_path_nas,
|
||||
resolution=req.resolution,
|
||||
duration_sec=req.duration_sec,
|
||||
style=req.style,
|
||||
)
|
||||
return result
|
||||
except video_encoder.EncodeError as e:
|
||||
# input_validation, path_translate → 400
|
||||
# ffmpeg, output_check → 500
|
||||
status_code = 400 if e.stage in ("input_validation", "path_translate") else 500
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail={"ok": False, "stage": e.stage, "error": e.message},
|
||||
)
|
||||
```
|
||||
|
||||
### Step 3: 확장된 `/health`
|
||||
|
||||
기존 `/health` 응답에 추가:
|
||||
```python
|
||||
import torch # if existing health uses it
|
||||
import video_encoder
|
||||
|
||||
# Module-level cache so health doesn't run ffmpeg every call
|
||||
_FFMPEG_NVENC_CACHED = None
|
||||
def _ffmpeg_nvenc_available():
|
||||
global _FFMPEG_NVENC_CACHED
|
||||
if _FFMPEG_NVENC_CACHED is None:
|
||||
_FFMPEG_NVENC_CACHED = video_encoder.check_ffmpeg_nvenc()
|
||||
return _FFMPEG_NVENC_CACHED
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {
|
||||
"ok": True,
|
||||
"gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, # 또는 기존 형식 유지
|
||||
"musicgen_loaded": True, # 기존 그대로
|
||||
"ffmpeg_path": video_encoder.FFMPEG_PATH,
|
||||
"ffmpeg_nvenc": _ffmpeg_nvenc_available(),
|
||||
}
|
||||
```
|
||||
|
||||
(기존 `/health`의 정확한 형식은 코드 읽고 매칭. 위는 예시.)
|
||||
|
||||
### Step 4: `.env.example` 업데이트
|
||||
|
||||
```env
|
||||
# Existing
|
||||
MODEL_NAME=facebook/musicgen-stereo-large
|
||||
OUTPUT_DIR=output
|
||||
SERVER_PORT=8765
|
||||
|
||||
# New for video encoder
|
||||
NAS_VOLUME_PREFIX=/volume1/
|
||||
WINDOWS_DRIVE_ROOT=Z:\
|
||||
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
|
||||
```
|
||||
|
||||
### Step 5: 수동 검증
|
||||
|
||||
```bash
|
||||
cd music_ai && start.bat # 또는 적절한 시작 명령
|
||||
curl http://localhost:8765/health
|
||||
# Expected: {..., "ffmpeg_nvenc": true}
|
||||
|
||||
curl -X POST http://localhost:8765/encode_video -H "Content-Type: application/json" -d '{
|
||||
"cover_path_nas": "/volume1/docker/webpage/data/videos/3/cover.jpg",
|
||||
"audio_path_nas": "/volume1/docker/webpage/data/1c695df3-8a82-4c09-ba7b-82c07608ec5b.mp3",
|
||||
"output_path_nas": "/volume1/docker/webpage/data/videos/test/video.mp4",
|
||||
"resolution": "1920x1080",
|
||||
"duration_sec": 176
|
||||
}'
|
||||
# Expected: 200 + duration_ms ~ 10-20초
|
||||
```
|
||||
|
||||
(실제 파일 경로는 사용자 환경에 맞게 조정)
|
||||
|
||||
### Step 6: Commit (music_ai is local-only, no remote)
|
||||
|
||||
---
|
||||
|
||||
## Task 3: NAS music-lab — `pipeline/video.py` 재작성 + 테스트
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `music-lab/app/pipeline/video.py`
|
||||
- Rewrite: `music-lab/tests/test_video_thumb.py` (video 부분만)
|
||||
|
||||
### Step 1: Replace failing tests
|
||||
|
||||
```python
|
||||
# music-lab/tests/test_video_thumb.py — video 관련 테스트 부분만 교체
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
from httpx import Response
|
||||
from app.pipeline import video, thumb, storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encoder_env(monkeypatch):
|
||||
monkeypatch.setenv("WINDOWS_VIDEO_ENCODER_URL", "http://192.168.45.59:8765")
|
||||
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
|
||||
|
||||
|
||||
@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={"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.setenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
||||
monkeypatch.setenv("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.setenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
||||
monkeypatch.setenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
|
||||
assert video._container_to_nas("/app/data/abc.mp3") == "/volume1/docker/webpage/data/music/abc.mp3"
|
||||
```
|
||||
|
||||
기존 `test_generate_video_calls_ffmpeg`, `test_generate_video_failure_marks_failed` 삭제. thumb 관련 테스트는 그대로 유지.
|
||||
|
||||
### Step 2: Run, verify fail
|
||||
|
||||
```bash
|
||||
cd music-lab && python -m pytest tests/test_video_thumb.py -v
|
||||
```
|
||||
|
||||
Expected: video 관련 테스트들이 실패 (또는 ImportError).
|
||||
|
||||
### Step 3: Rewrite `app/pipeline/video.py`
|
||||
|
||||
```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:
|
||||
detail = resp.json().get("detail", resp.json())
|
||||
except Exception:
|
||||
detail = {"error": resp.text[:300]}
|
||||
stage = detail.get("stage", "?") if isinstance(detail, dict) else "?"
|
||||
error = detail.get("error", str(detail)) if isinstance(detail, dict) else 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
|
||||
```
|
||||
|
||||
### Step 4: Run tests
|
||||
|
||||
```bash
|
||||
cd music-lab && python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: 73 PASS — 2 (제거) + 6 (신규) = 77? 아니면 73 그대로 — count 확인.
|
||||
|
||||
### Step 5: Commit + push
|
||||
|
||||
```bash
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add music-lab/app/pipeline/video.py \
|
||||
music-lab/tests/test_video_thumb.py
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(music-lab): 영상 인코딩을 Windows GPU 서버로 오프로드
|
||||
|
||||
- pipeline/video.py 재작성: subprocess.run 제거, httpx로 192.168.45.59:8765/encode_video 호출
|
||||
- Windows 서버 다운 시 즉시 VideoGenerationError (NAS 로컬 폴백 X)
|
||||
- /app/data/* → /volume1/docker/webpage/data/* 경로 변환 (_container_to_nas)
|
||||
- 테스트는 respx mock 기반으로 교체 (6개 신규)"
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: docker-compose.yml env 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/docker-compose.yml`
|
||||
|
||||
### Step 1: music-lab 서비스 environment에 추가
|
||||
|
||||
```yaml
|
||||
music-lab:
|
||||
environment:
|
||||
# ... existing ...
|
||||
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL}
|
||||
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
||||
- NAS_MUSIC_ROOT=${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
|
||||
```
|
||||
|
||||
### Step 2: docker-compose syntax 검증
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -c "import yaml; yaml.safe_load(open('docker-compose.yml'))" && echo OK
|
||||
```
|
||||
|
||||
### Step 3: Commit + push
|
||||
|
||||
```bash
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add docker-compose.yml
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "chore(infra): GPU 인코더 env 추가 (WINDOWS_VIDEO_ENCODER_URL)"
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 사용자 매뉴얼 단계 (사람이 직접)
|
||||
|
||||
후속 단계, 코드 작업 아님:
|
||||
|
||||
1. **Windows PC: ffmpeg 설치 + PATH 설정**
|
||||
- https://www.gyan.dev/ffmpeg/builds/ → "release full" 다운로드
|
||||
- `C:\ffmpeg\` 압축 해제 → `C:\ffmpeg\bin\ffmpeg.exe` 확인
|
||||
- 시스템 PATH에 `C:\ffmpeg\bin` 추가
|
||||
- 검증: `ffmpeg -version` + `ffmpeg -encoders | findstr h264_nvenc`
|
||||
|
||||
2. **Windows PC: `music_ai/.env` 추가**
|
||||
```env
|
||||
NAS_VOLUME_PREFIX=/volume1/
|
||||
WINDOWS_DRIVE_ROOT=Z:\
|
||||
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
|
||||
```
|
||||
|
||||
3. **Windows PC: SMB 마운트 확인** — `Z:\docker\webpage\data\` 접근 가능
|
||||
|
||||
4. **Windows PC: `music_ai` 서버 재시작** — `start.bat`
|
||||
|
||||
5. **Windows PC 헬스 체크** — `curl http://localhost:8765/health` → `ffmpeg_nvenc: true` 확인
|
||||
|
||||
6. **NAS `.env`에 추가**
|
||||
```env
|
||||
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
|
||||
```
|
||||
|
||||
7. **NAS music-lab 재시작** — `docker compose up -d music-lab`
|
||||
|
||||
8. **E2E 테스트** — 진행 탭에서 새 파이프라인 시작, 영상 단계가 10–20초에 완료되는지 확인
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- §4 Windows endpoint → Task 1, 2 ✓
|
||||
- §5 NAS video.py → Task 3 ✓
|
||||
- §6 에러 처리 → Task 3 (httpx 예외 catch) ✓
|
||||
- §7 헬스 모니터링 → Task 2 (`/health` 확장) ✓
|
||||
- §8 테스트 → Task 1, 3 ✓
|
||||
- §9 Windows 사전 준비 → Task 5 (사용자 수동) ✓
|
||||
- §10 산출물 → 4 task로 모두 커버
|
||||
|
||||
**Placeholder scan:** 없음.
|
||||
|
||||
**Type consistency:**
|
||||
- `EncodeError(stage, message)` Task 1 정의, Task 2에서 `e.stage`/`e.message` 사용 ✓
|
||||
- `VideoGenerationError` Task 3에서 raise, 기존 orchestrator에서 catch ✓
|
||||
- 응답 JSON 형식 spec §4-2와 일치 ✓
|
||||
- 환경변수 이름 일관 (`NAS_VOLUME_PREFIX`, `WINDOWS_DRIVE_ROOT`, `FFMPEG_PATH`, `WINDOWS_VIDEO_ENCODER_URL`, `NAS_VIDEOS_ROOT`, `NAS_MUSIC_ROOT`)
|
||||
|
||||
---
|
||||
486
docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md
Normal file
486
docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md
Normal file
@@ -0,0 +1,486 @@
|
||||
# GPU 영상 인코딩 오프로드 — 설계
|
||||
|
||||
> 작성일: 2026-05-09
|
||||
> 관련: `2026-05-07-music-youtube-pipeline-design.md` (Task 4 대체)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경
|
||||
|
||||
NAS Synology Celeron J4025(2 cores @ 2.0GHz, GPU 없음)에서 1920×1080 visualizer 영상 인코딩이 너무 느림. 176초 트랙 인코딩에 5분 초과 → ffmpeg `subprocess.TimeoutExpired`. `-preset ultrafast`로 가속해도 한계 있고 화질 저하.
|
||||
|
||||
대안: 사용자 Windows PC(RTX 5070 Ti, 16GB VRAM)에서 NVIDIA NVENC 하드웨어 인코딩으로 처리. 같은 영상이 **10–20초**에 완료(20×+ 빠름).
|
||||
|
||||
이미 `music_ai` 서버(Windows, port 8765)가 MusicGen용으로 동작 중이므로 **같은 서버에 영상 인코딩 endpoint를 추가**하는 것이 가장 자연스럽다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 비목표
|
||||
|
||||
- 다중 GPU/멀티 머신 — 단일 Windows PC만 지원
|
||||
- NAS 로컬 ffmpeg 폴백 — 사용자 결정으로 제외 (Windows 서버 다운 시 명확한 실패 선호)
|
||||
- 영상 길이 제한 — 일반 트랙 길이(1–10분) 가정
|
||||
- 인증 — LAN 전용, 무인증
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ NAS (Synology) │
|
||||
│ │
|
||||
│ music-lab container │
|
||||
│ pipeline/video.py │
|
||||
│ ↓ HTTP POST {paths, resolution} │
|
||||
│ ↓ 192.168.45.59:8765/encode_video │
|
||||
│ │
|
||||
│ /volume1/docker/webpage/data/ │
|
||||
│ videos/{id}/cover.jpg ← input │
|
||||
│ videos/{id}/video.mp4 ← output (Windows가 직접 씀) │
|
||||
│ {audio}.mp3 ← input │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
↓ HTTP ↑ SMB read/write
|
||||
↓ ↑ (Z:\ 마운트)
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Windows PC (192.168.45.59) │
|
||||
│ │
|
||||
│ music_ai server.py (port 8765) │
|
||||
│ • POST /generate (기존, MusicGen) │
|
||||
│ • POST /encode_video (신규) │
|
||||
│ ↓ 경로 변환: /volume1/... → Z:\... │
|
||||
│ ↓ ffmpeg.exe -hwaccel cuda -c:v h264_nvenc ... │
|
||||
│ ↓ 입력/출력 모두 Z:\ 직접 (SMB) │
|
||||
│ ↓ 응답: {ok, duration_ms, output_path} │
|
||||
│ │
|
||||
│ Z:\docker\webpage\data\ (NAS SMB mount, 기존) │
|
||||
│ videos\{id}\cover.jpg │
|
||||
│ videos\{id}\video.mp4 │
|
||||
│ {audio}.mp3 │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**핵심 원칙:** 파일은 SMB로 직접 읽고 쓰기 — HTTP는 메타데이터(경로 + 옵션)만 전달.
|
||||
|
||||
---
|
||||
|
||||
## 4. Windows `music_ai` 서버 — `/encode_video` endpoint
|
||||
|
||||
### 4-1. Request
|
||||
|
||||
```http
|
||||
POST /encode_video HTTP/1.1
|
||||
Host: 192.168.45.59:8765
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `cover_path_nas` | string | ✓ | 배경 이미지 NAS 절대경로 |
|
||||
| `audio_path_nas` | string | ✓ | 오디오 파일 NAS 절대경로 |
|
||||
| `output_path_nas` | string | ✓ | 출력 mp4 NAS 절대경로 |
|
||||
| `resolution` | string | ✓ | `WIDTHxHEIGHT` (예: `1920x1080`) |
|
||||
| `duration_sec` | int | | 트랙 길이 — 진행 추적용 (옵션) |
|
||||
| `style` | string | | 현재 `visualizer`만 (확장용) |
|
||||
|
||||
### 4-2. Response
|
||||
|
||||
**성공 (200):**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"duration_ms": 12340,
|
||||
"output_path_nas": "/volume1/docker/webpage/data/videos/3/video.mp4",
|
||||
"output_bytes": 28470000,
|
||||
"encoder": "h264_nvenc",
|
||||
"preset": "p4"
|
||||
}
|
||||
```
|
||||
|
||||
**실패 (4xx/5xx):**
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "ffmpeg returncode=1: ...",
|
||||
"stage": "ffmpeg" // path_translate | input_validation | ffmpeg | output_check
|
||||
}
|
||||
```
|
||||
|
||||
### 4-3. 경로 변환
|
||||
|
||||
Windows 서버는 `nas_path → windows_path` 변환을 환경변수 기반으로 수행:
|
||||
|
||||
```python
|
||||
# .env (Windows music_ai)
|
||||
NAS_VOLUME_PREFIX=/volume1/
|
||||
WINDOWS_DRIVE_ROOT=Z:\
|
||||
```
|
||||
|
||||
변환 로직:
|
||||
```python
|
||||
def translate_path(nas_path: str) -> str:
|
||||
# /volume1/docker/webpage/data/videos/3/cover.jpg
|
||||
# → Z:\docker\webpage\data\videos\3\cover.jpg
|
||||
if not nas_path.startswith(NAS_VOLUME_PREFIX):
|
||||
raise ValueError(f"NAS prefix 불일치: {nas_path}")
|
||||
rel = nas_path[len(NAS_VOLUME_PREFIX):] # "docker/webpage/..."
|
||||
return WINDOWS_DRIVE_ROOT + rel.replace("/", "\\")
|
||||
```
|
||||
|
||||
### 4-4. 입력 검증
|
||||
|
||||
ffmpeg 호출 전:
|
||||
- `cover_path` 변환된 Windows 경로의 파일 존재 확인 → 없으면 400 stage=input_validation
|
||||
- `audio_path` 동일
|
||||
- `output_path`의 부모 디렉토리 존재 확인 — 없으면 자동 생성
|
||||
- `resolution` 정규식 `^\d{3,4}x\d{3,4}$` 검증 → 실패 시 400
|
||||
|
||||
### 4-5. ffmpeg 명령 (NVENC)
|
||||
|
||||
```python
|
||||
def build_visualizer_cmd(cover_win, audio_win, out_win, w, h):
|
||||
return [
|
||||
"ffmpeg", "-y",
|
||||
"-hwaccel", "cuda",
|
||||
"-loop", "1", "-i", cover_win,
|
||||
"-i", audio_win,
|
||||
"-filter_complex",
|
||||
f"[0:v]scale={w}:{h},format=yuv420p[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", "h264_nvenc",
|
||||
"-preset", "p4", # quality preset (p1=fastest, p7=slowest/best)
|
||||
"-rc", "vbr",
|
||||
"-cq", "23", # quality (lower=better, 18-25 sane range)
|
||||
"-b:v", "0", # let CQ control bitrate
|
||||
"-pix_fmt", "yuv420p", # YouTube 호환
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-shortest", out_win,
|
||||
]
|
||||
```
|
||||
|
||||
**주요 플래그 설명:**
|
||||
- `-hwaccel cuda` — CUDA 사용
|
||||
- `-c:v h264_nvenc` — NVIDIA NVENC H.264 인코더
|
||||
- `-preset p4` — 품질·속도 균형 (5070 Ti 기준 1080p 영상 ~10–20s)
|
||||
- `-rc vbr -cq 23 -b:v 0` — VBR + 일정 품질 (CQ 23 = ~CRF 23)
|
||||
- `format=yuv420p` 명시 — NVENC가 가끔 yuv444 출력하는데 YouTube 호환 X
|
||||
|
||||
### 4-6. 타임아웃 + 출력 검증
|
||||
|
||||
- ffmpeg subprocess timeout: **180초** (NAS 측 HTTP timeout 200s 미만)
|
||||
- 종료 후 출력 파일 존재 + 크기 > 1MB 검증 → 미달 시 stage=output_check 실패
|
||||
- 종료 코드 0이지만 파일 비어있는 케이스 catch
|
||||
|
||||
### 4-7. 동시 처리
|
||||
|
||||
별도 큐 없음. 동시 호출 시 ffmpeg 프로세스 병렬 실행 — RTX 5070 Ti는 NVENC 세션 5개까지 지원.
|
||||
|
||||
단일 사용자 시나리오에서 동시 인코딩은 거의 발생 안 함. 발생해도 GPU 리소스 충분.
|
||||
|
||||
### 4-8. 헬스 체크 확장
|
||||
|
||||
기존 `GET /health`에 인코더 가용성 정보 추가:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"gpu": "NVIDIA GeForce RTX 5070 Ti",
|
||||
"musicgen_loaded": true,
|
||||
"ffmpeg_path": "C:/ffmpeg/bin/ffmpeg.exe",
|
||||
"ffmpeg_nvenc": true
|
||||
}
|
||||
```
|
||||
|
||||
`ffmpeg_nvenc` 검증: 서버 시작 시 `ffmpeg -encoders | grep h264_nvenc` 한 번 실행 + 캐시.
|
||||
|
||||
---
|
||||
|
||||
## 5. NAS music-lab — `pipeline/video.py` 리팩토링
|
||||
|
||||
### 5-1. 환경변수 (필수)
|
||||
|
||||
```env
|
||||
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
|
||||
```
|
||||
|
||||
미설정 시: `pipeline/video.py`가 기동 시 명확한 에러로 실패 (ImportError 또는 RuntimeError).
|
||||
|
||||
### 5-2. `video.generate(...)` — 새 구현
|
||||
|
||||
```python
|
||||
"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출."""
|
||||
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 + 마진
|
||||
|
||||
|
||||
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 서버 호출. 다운/실패 시 즉시 예외."""
|
||||
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 인코더 호출: %s → %s", audio_path, out_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) as e:
|
||||
raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
detail = resp.json()
|
||||
except Exception:
|
||||
detail = {"error": resp.text[:300]}
|
||||
raise VideoGenerationError(
|
||||
f"Windows 인코더 오류 ({resp.status_code}): "
|
||||
f"{detail.get('stage','?')} — {detail.get('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
|
||||
"""
|
||||
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")
|
||||
if container_path.startswith("/app/data/videos/"):
|
||||
return container_path.replace("/app/data/videos/", nas_videos_root + "/", 1)
|
||||
if container_path.startswith("/app/data/"):
|
||||
# 음악 파일 마운트가 /app/data 직접이라 서브디렉토리 없음 → music root에 직접
|
||||
rel = container_path[len("/app/data/"):]
|
||||
return nas_music_root + "/" + rel
|
||||
return container_path # fallback (shouldn't happen)
|
||||
```
|
||||
|
||||
### 5-3. 제거 항목
|
||||
|
||||
- `subprocess.run(...)` ffmpeg 호출 — 완전 제거
|
||||
- `VIDEO_TIMEOUT_S = 600` — 사용 안 함 (`ENCODER_TIMEOUT_S`로 대체)
|
||||
- `_build_visualizer_cmd` — 제거 (Windows 서버로 이전)
|
||||
- `subprocess.TimeoutExpired` 예외 처리 — 제거
|
||||
|
||||
### 5-4. 환경변수 (NAS music-lab)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml music-lab service environment
|
||||
WINDOWS_VIDEO_ENCODER_URL: ${WINDOWS_VIDEO_ENCODER_URL}
|
||||
NAS_VIDEOS_ROOT: ${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
||||
NAS_MUSIC_ROOT: ${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
|
||||
```
|
||||
|
||||
NAS `.env` 추가:
|
||||
```env
|
||||
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 에러 응답 매트릭스
|
||||
|
||||
| 상황 | NAS 측 결과 | 사용자 경험 |
|
||||
|------|------------|-------------|
|
||||
| Windows PC 꺼짐 | `VideoGenerationError("연결 실패")` | 진행 카드 `failed`, 텔레그램에 명확한 에러 |
|
||||
| Windows ffmpeg 실패 | `VideoGenerationError("Windows 인코더 오류 500: ffmpeg — ...")` | 동일 |
|
||||
| 입력 파일 NAS에 없음 | Windows가 400 응답 | "input_validation: cover not found" 메시지 |
|
||||
| 출력 파일이 비어있음 | Windows가 500 응답 | "output_check: file empty" |
|
||||
| 타임아웃 (180s+) | Windows가 504 응답 또는 connection close | "타임아웃 — GPU 부하 또는 입력 손상" |
|
||||
| WINDOWS_VIDEO_ENCODER_URL 미설정 | 즉시 `VideoGenerationError` | 환경 미설정 안내 |
|
||||
|
||||
모두 pipeline state `failed`로 전이. 재생성 5회 한도 적용.
|
||||
|
||||
---
|
||||
|
||||
## 7. 헬스 모니터링
|
||||
|
||||
NAS music-lab 시작 시 1회 `GET {ENCODER_URL}/health` 호출 → 결과를 로그에 출력:
|
||||
- 성공 + `ffmpeg_nvenc=true` → 인코더 사용 가능
|
||||
- 실패 → 경고 로그 (구동은 계속, 호출 시점에 명확한 에러)
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 전략
|
||||
|
||||
### 8-1. NAS music-lab 단위 테스트
|
||||
|
||||
`music-lab/tests/test_video_thumb.py` — 기존 ffmpeg 테스트를 HTTP mock 기반으로 교체:
|
||||
|
||||
```python
|
||||
@respx.mock
|
||||
def test_generate_video_calls_remote_encoder(monkeypatch):
|
||||
monkeypatch.setenv("WINDOWS_VIDEO_ENCODER_URL", "http://192.168.45.59:8765")
|
||||
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
|
||||
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/...",
|
||||
"encoder": "h264_nvenc", "preset": "p4"
|
||||
})
|
||||
)
|
||||
out = video.generate(...)
|
||||
assert out["url"].endswith("/video.mp4")
|
||||
assert out["encode_duration_ms"] == 12000
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_generate_video_raises_on_connection_error(monkeypatch):
|
||||
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
|
||||
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(...)
|
||||
assert "연결 실패" in str(exc.value)
|
||||
|
||||
|
||||
def test_generate_video_no_url_configured(monkeypatch):
|
||||
monkeypatch.setattr(video, "ENCODER_URL", "")
|
||||
with pytest.raises(video.VideoGenerationError) as exc:
|
||||
video.generate(...)
|
||||
assert "WINDOWS_VIDEO_ENCODER_URL" in str(exc.value)
|
||||
```
|
||||
|
||||
기존 `test_generate_video_calls_ffmpeg` / `test_generate_video_failure_marks_failed` 제거.
|
||||
|
||||
### 8-2. Windows `music_ai` 단위 테스트
|
||||
|
||||
`music_ai/tests/test_video_encoder.py` (신규):
|
||||
|
||||
```python
|
||||
@patch("subprocess.run")
|
||||
def test_translate_path():
|
||||
assert video_encoder.translate_path("/volume1/docker/webpage/data/x.jpg") == r"Z:\docker\webpage\data\x.jpg"
|
||||
|
||||
def test_translate_path_rejects_bad_prefix():
|
||||
with pytest.raises(ValueError):
|
||||
video_encoder.translate_path("/something/else/x.jpg")
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_endpoint_success(mock_run, client, tmp_path):
|
||||
# mock paths exist + ffmpeg succeeds
|
||||
...
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_endpoint_input_missing(mock_run, client):
|
||||
# 입력 파일 안 보이면 400
|
||||
...
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_endpoint_ffmpeg_fails(mock_run, client, tmp_path):
|
||||
# ffmpeg returncode=1 → 500 stage=ffmpeg
|
||||
...
|
||||
```
|
||||
|
||||
### 8-3. 통합 테스트
|
||||
|
||||
기존 `test_pipeline_flow.py`는 `cover.generate`를 mock하므로 영향 없음. video도 같이 mock — 변경 없음.
|
||||
|
||||
### 8-4. 수동 E2E
|
||||
|
||||
- [ ] Windows PC에서 `music_ai` 서버 시작 → `curl http://192.168.45.59:8765/health` → `ffmpeg_nvenc: true` 확인
|
||||
- [ ] NAS에서 `curl -X POST http://192.168.45.59:8765/encode_video -d '{...}'` 직접 호출 → 200 응답 + Z:\에 video.mp4 생성 확인
|
||||
- [ ] 진행 탭에서 새 파이프라인 시작 → 영상 단계가 10–20초 안에 완료 → 텔레그램 알림 도착
|
||||
- [ ] Windows PC 꺼두고 새 파이프라인 시작 → 영상 단계 즉시 실패 → 진행 카드 failed + 명확한 에러 메시지
|
||||
|
||||
---
|
||||
|
||||
## 9. Windows PC 사전 준비
|
||||
|
||||
사용자가 Windows PC에서 1회 수행할 작업:
|
||||
|
||||
1. **ffmpeg + NVENC 빌드 설치**
|
||||
- https://www.gyan.dev/ffmpeg/builds/ → "release full" 다운로드
|
||||
- 압축 해제 → `C:\ffmpeg\bin\ffmpeg.exe`
|
||||
- PATH 환경변수에 `C:\ffmpeg\bin` 추가
|
||||
- 검증: `ffmpeg -version` 동작, `ffmpeg -encoders | findstr h264_nvenc` 결과 출력
|
||||
|
||||
2. **NVIDIA 드라이버** — 이미 MusicGen용으로 설치돼 있음
|
||||
|
||||
3. **SMB 마운트 확인** — `Z:\docker\webpage\` 접근 가능해야 함
|
||||
|
||||
4. **방화벽** — 포트 8765 LAN 인바운드 허용 (이미 MusicGen용으로 설정돼 있음)
|
||||
|
||||
5. **`music_ai/.env`에 추가**:
|
||||
```env
|
||||
NAS_VOLUME_PREFIX=/volume1/
|
||||
WINDOWS_DRIVE_ROOT=Z:\
|
||||
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
|
||||
```
|
||||
|
||||
6. **`music_ai/start.bat` 재시작** — 새 endpoint 활성화
|
||||
|
||||
---
|
||||
|
||||
## 10. 산출물
|
||||
|
||||
| 영역 | 파일 |
|
||||
|------|------|
|
||||
| Windows | `music_ai/video_encoder.py` (신규) |
|
||||
| Windows | `music_ai/server.py` (수정 — `/encode_video` endpoint 등록, `/health` 확장) |
|
||||
| Windows | `music_ai/.env.example` (수정 — 새 변수 문서화) |
|
||||
| Windows | `music_ai/tests/test_video_encoder.py` (신규) |
|
||||
| NAS | `music-lab/app/pipeline/video.py` (재작성) |
|
||||
| NAS | `music-lab/tests/test_video_thumb.py` (수정 — HTTP mock 기반) |
|
||||
| Infra | `web-backend/docker-compose.yml` (env 3개 추가) |
|
||||
| Infra | NAS `.env` (사용자 수동, 1개 추가) |
|
||||
|
||||
---
|
||||
|
||||
## 11. 후속
|
||||
|
||||
- (P3) 영상 인코딩 진행률 실시간 보고 — Windows에서 ffmpeg progress 파싱 후 진행 탭 카드에 표시 (현재는 단순 "running")
|
||||
- (P3) Windows 서버 다중 큐 — 동시 요청 시 GPU 부하 추적 + 큐잉
|
||||
- (P4) 인코딩 옵션을 youtube_setup `visual_defaults`로 추가 — preset(p1~p7), CQ, 해상도 옵션 노출
|
||||
- (P4) Shorts 전용 1080×1920 인코딩 프로파일
|
||||
|
||||
---
|
||||
## 11. 후속
|
||||
|
||||
- (P3) 영상 인코딩 진행률 실시간 보고 — Windows에서 ffmpeg progress 파싱 후 진행 탭 카드에 표시 (현재는 단순 "running")
|
||||
- (P3) Windows 서버 다중 큐 — 동시 요청 시 GPU 부하 추적 + 큐잉
|
||||
- (P4) 인코딩 옵션을 youtube_setup `visual_defaults`로 추가 — preset(p1~p7), CQ, 해상도 옵션 노출
|
||||
- (P4) Shorts 전용 1080×1920 인코딩 프로파일
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user