Files
web-page-backend/docs/superpowers/specs/2026-05-09-gpu-video-offload-design.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

487 lines
18 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 영상 인코딩 오프로드 — 설계
> 작성일: 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 하드웨어 인코딩으로 처리. 같은 영상이 **1020초**에 완료(20×+ 빠름).
이미 `music_ai` 서버(Windows, port 8765)가 MusicGen용으로 동작 중이므로 **같은 서버에 영상 인코딩 endpoint를 추가**하는 것이 가장 자연스럽다.
---
## 2. 비목표
- 다중 GPU/멀티 머신 — 단일 Windows PC만 지원
- NAS 로컬 ffmpeg 폴백 — 사용자 결정으로 제외 (Windows 서버 다운 시 명확한 실패 선호)
- 영상 길이 제한 — 일반 트랙 길이(110분) 가정
- 인증 — 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 영상 ~1020s)
- `-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 생성 확인
- [ ] 진행 탭에서 새 파이프라인 시작 → 영상 단계가 1020초 안에 완료 → 텔레그램 알림 도착
- [ ] 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 인코딩 프로파일
---