# 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 인코딩 프로파일 ---