# 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":  "/volume1/docker/webpage/data/videos/3/cover.jpg",
  "audio_path_nas":  "/volume1/docker/webpage/data/1c695df3-...mp3",
  "output_path_nas": "/volume1/docker/webpage/data/videos/3/video.mp4",
  "resolution":      "1920x1080",
  "duration_sec":    176,
  "style":           "visualizer"
}
```

| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `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 인코딩 프로파일

---
