Files
web-page-backend/docs/superpowers/plans/2026-05-09-gpu-video-offload.md
gahusb bb0b0dff25 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>
2026-05-09 01:52:34 +09:00

738 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 테스트** — 진행 탭에서 새 파이프라인 시작, 영상 단계가 1020초에 완료되는지 확인
---
## 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`)
---