# 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`)

---
