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

18 KiB
Raw Permalink Blame History

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

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_validation
  • audio_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 영상 ~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에 인코더 가용성 정보 추가:

{
  "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.pycover.generate를 mock하므로 영향 없음. video도 같이 mock — 변경 없음.

8-4. 수동 E2E

  • Windows PC에서 music_ai 서버 시작 → curl http://192.168.45.59:8765/healthffmpeg_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에 추가:

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