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>
18 KiB
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
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):
{
"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):
{
"ok": false,
"error": "ffmpeg returncode=1: ...",
"stage": "ffmpeg" // path_translate | input_validation | ffmpeg | output_check
}
4-3. 경로 변환
Windows 서버는 nas_path → windows_path 변환을 환경변수 기반으로 수행:
# .env (Windows music_ai)
NAS_VOLUME_PREFIX=/volume1/
WINDOWS_DRIVE_ROOT=Z:\
변환 로직:
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_validationaudio_path동일output_path의 부모 디렉토리 존재 확인 — 없으면 자동 생성resolution정규식^\d{3,4}x\d{3,4}$검증 → 실패 시 400
4-5. ffmpeg 명령 (NVENC)
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에 인코더 가용성 정보 추가:
{
"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. 환경변수 (필수)
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
미설정 시: pipeline/video.py가 기동 시 명확한 에러로 실패 (ImportError 또는 RuntimeError).
5-2. video.generate(...) — 새 구현
"""영상 비주얼 생성 — 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)
# 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 추가:
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 기반으로 교체:
@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 (신규):
@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회 수행할 작업:
-
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결과 출력
-
NVIDIA 드라이버 — 이미 MusicGen용으로 설치돼 있음
-
SMB 마운트 확인 —
Z:\docker\webpage\접근 가능해야 함 -
방화벽 — 포트 8765 LAN 인바운드 허용 (이미 MusicGen용으로 설정돼 있음)
-
music_ai/.env에 추가:NAS_VOLUME_PREFIX=/volume1/ WINDOWS_DRIVE_ROOT=Z:\ FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe -
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 인코딩 프로파일