diff --git a/docs/superpowers/plans/2026-05-09-gpu-video-offload.md b/docs/superpowers/plans/2026-05-09-gpu-video-offload.md new file mode 100644 index 0000000..ad78e86 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-gpu-video-offload.md @@ -0,0 +1,737 @@ +# GPU 영상 인코딩 오프로드 — 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. + +**Goal:** NAS의 ffmpeg 영상 인코딩을 Windows PC(RTX 5070 Ti) NVENC로 오프로드. + +**Architecture:** music-lab(NAS) → HTTP POST → music_ai(Windows, port 8765 `/encode_video`) → ffmpeg NVENC → SMB로 NAS에 직접 mp4 저장. Windows 서버 다운 시 NAS는 즉시 실패. + +**Tech Stack:** httpx (NAS 측 HTTP 클라이언트), FastAPI (Windows 서버 endpoint), ffmpeg.exe with NVENC. + +**Spec:** `docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md` + +--- + +## File Structure + +| 경로 | 책임 | +|------|------| +| `music_ai/video_encoder.py` (new) | 경로 변환 + ffmpeg NVENC subprocess 호출 + 검증 | +| `music_ai/server.py` (modify) | `/encode_video` POST endpoint 등록, `/health`에 ffmpeg/nvenc 정보 추가 | +| `music_ai/.env.example` (modify) | NAS_VOLUME_PREFIX, WINDOWS_DRIVE_ROOT, FFMPEG_PATH 문서화 | +| `music_ai/tests/test_video_encoder.py` (new) | translate_path, encode endpoint 단위 테스트 | +| `music-lab/app/pipeline/video.py` (rewrite) | subprocess 제거, httpx로 Windows 서버 호출 | +| `music-lab/tests/test_video_thumb.py` (rewrite video tests) | respx mock 기반 | +| `web-backend/docker-compose.yml` (modify) | music-lab env 3개 추가 | + +--- + +## Task 1: Windows `music_ai/video_encoder.py` + 테스트 + +**Files:** +- Create: `music_ai/video_encoder.py` +- Create: `music_ai/tests/test_video_encoder.py` + +### Step 1: Write failing test + +```python +# music_ai/tests/test_video_encoder.py +import os +import pytest +from unittest.mock import patch, MagicMock +from video_encoder import translate_path, encode_video, EncodeError + + +@pytest.fixture +def env(monkeypatch): + monkeypatch.setenv("NAS_VOLUME_PREFIX", "/volume1/") + monkeypatch.setenv("WINDOWS_DRIVE_ROOT", "Z:\\") + monkeypatch.setenv("FFMPEG_PATH", "C:\\ffmpeg\\bin\\ffmpeg.exe") + + +def test_translate_path_basic(env): + assert translate_path("/volume1/docker/webpage/data/x.jpg") == r"Z:\docker\webpage\data\x.jpg" + + +def test_translate_path_nested(env): + assert translate_path("/volume1/docker/webpage/data/videos/3/cover.jpg") == r"Z:\docker\webpage\data\videos\3\cover.jpg" + + +def test_translate_path_rejects_bad_prefix(env): + with pytest.raises(ValueError): + translate_path("/etc/passwd") + + +@patch("subprocess.run") +def test_encode_video_success(mock_run, env, tmp_path): + # 입력 파일 fake + cover = tmp_path / "cover.jpg" + cover.write_bytes(b"\x00" * 100) + audio = tmp_path / "audio.mp3" + audio.write_bytes(b"\x00" * 100) + out = tmp_path / "video.mp4" + + def fake_run(cmd, **kwargs): + # ffmpeg 실행을 흉내내어 출력 파일을 만듦 + out.write_bytes(b"\x00" * (2 * 1024 * 1024)) # 2MB + return MagicMock(returncode=0, stderr="") + mock_run.side_effect = fake_run + + # translate_path를 mock해서 입력 경로를 직접 사용 + with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")): + result = encode_video( + cover_path_nas="/volume1/cover.jpg", + audio_path_nas="/volume1/audio.mp3", + output_path_nas="/volume1/video.mp4", + resolution="1920x1080", + duration_sec=120, + ) + assert result["ok"] is True + assert result["encoder"] == "h264_nvenc" + assert result["output_bytes"] > 1024 * 1024 + + +@patch("subprocess.run") +def test_encode_video_input_missing(mock_run, env, tmp_path): + with pytest.raises(EncodeError) as exc: + encode_video( + cover_path_nas="/volume1/missing.jpg", + audio_path_nas="/volume1/missing.mp3", + output_path_nas="/volume1/out.mp4", + resolution="1920x1080", + duration_sec=120, + ) + assert "input_validation" in str(exc.value) + + +@patch("subprocess.run") +def test_encode_video_ffmpeg_failure(mock_run, env, tmp_path): + cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00") + audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00") + mock_run.return_value = MagicMock(returncode=1, stderr="invalid codec\n" * 50) + + with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")): + with pytest.raises(EncodeError) as exc: + encode_video( + cover_path_nas="/volume1/cover.jpg", + audio_path_nas="/volume1/audio.mp3", + output_path_nas="/volume1/out.mp4", + resolution="1920x1080", + duration_sec=120, + ) + assert "ffmpeg" in str(exc.value).lower() + + +@patch("subprocess.run") +def test_encode_video_output_too_small(mock_run, env, tmp_path): + cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00") + audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00") + def fake_run(cmd, **kwargs): + (tmp_path / "out.mp4").write_bytes(b"\x00" * 100) # 100 bytes — too small + return MagicMock(returncode=0, stderr="") + mock_run.side_effect = fake_run + + with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")): + with pytest.raises(EncodeError) as exc: + encode_video( + cover_path_nas="/volume1/cover.jpg", + audio_path_nas="/volume1/audio.mp3", + output_path_nas="/volume1/out.mp4", + resolution="1920x1080", + duration_sec=120, + ) + assert "output_check" in str(exc.value) + + +def test_resolution_validation(env): + with pytest.raises(EncodeError) as exc: + encode_video( + cover_path_nas="/volume1/x.jpg", + audio_path_nas="/volume1/x.mp3", + output_path_nas="/volume1/out.mp4", + resolution="invalid", + duration_sec=120, + ) + assert "resolution" in str(exc.value).lower() +``` + +### Step 2: Run test to verify it fails + +```bash +cd music_ai && python -m pytest tests/test_video_encoder.py -v +``` + +Expected: ImportError on `video_encoder` module. + +### Step 3: Implement `video_encoder.py` + +```python +"""GPU(NVENC) 영상 인코더 — NAS music-lab에서 호출.""" +import os +import re +import subprocess +import logging + +logger = logging.getLogger("music_ai.video_encoder") + +NAS_VOLUME_PREFIX = os.getenv("NAS_VOLUME_PREFIX", "/volume1/") +WINDOWS_DRIVE_ROOT = os.getenv("WINDOWS_DRIVE_ROOT", "Z:\\") +FFMPEG_PATH = os.getenv("FFMPEG_PATH", "ffmpeg") +FFMPEG_TIMEOUT_S = 180 +RESOLUTION_RE = re.compile(r"^\d{3,4}x\d{3,4}$") +MIN_OUTPUT_BYTES = 1024 * 1024 # 1MB + + +class EncodeError(Exception): + """{stage: input_validation|path_translate|ffmpeg|output_check, message: ...}""" + def __init__(self, stage: str, message: str): + self.stage = stage + self.message = message + super().__init__(f"[{stage}] {message}") + + +def translate_path(nas_path: str) -> str: + """NAS 절대경로 → Windows SMB 경로.""" + if not nas_path.startswith(NAS_VOLUME_PREFIX): + raise ValueError(f"NAS prefix 불일치: {nas_path}") + rel = nas_path[len(NAS_VOLUME_PREFIX):] + return WINDOWS_DRIVE_ROOT + rel.replace("/", "\\") + + +def encode_video(*, cover_path_nas: str, audio_path_nas: str, + output_path_nas: str, resolution: str, + duration_sec: int = 0, style: str = "visualizer") -> dict: + """영상 인코딩 + Z:\\에 직접 저장.""" + # 1) Resolution 검증 + if not RESOLUTION_RE.match(resolution): + raise EncodeError("input_validation", f"invalid resolution: {resolution}") + w, h = resolution.split("x") + + # 2) 경로 변환 + try: + cover_win = translate_path(cover_path_nas) + audio_win = translate_path(audio_path_nas) + out_win = translate_path(output_path_nas) + except ValueError as e: + raise EncodeError("path_translate", str(e)) + + # 3) 입력 존재 확인 + if not os.path.isfile(cover_win): + raise EncodeError("input_validation", f"cover not found: {cover_win}") + if not os.path.isfile(audio_win): + raise EncodeError("input_validation", f"audio not found: {audio_win}") + + # 4) 출력 디렉토리 보장 + os.makedirs(os.path.dirname(out_win), exist_ok=True) + + # 5) ffmpeg 명령 + cmd = [ + FFMPEG_PATH, "-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", + "-rc", "vbr", + "-cq", "23", + "-b:v", "0", + "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "192k", + "-shortest", out_win, + ] + logger.info("ffmpeg: %s", " ".join(cmd)) + + # 6) ffmpeg 실행 + import time + t0 = time.time() + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=FFMPEG_TIMEOUT_S) + except subprocess.TimeoutExpired: + raise EncodeError("ffmpeg", f"timeout after {FFMPEG_TIMEOUT_S}s") + duration_ms = int((time.time() - t0) * 1000) + + if result.returncode != 0: + raise EncodeError("ffmpeg", f"returncode={result.returncode}: {result.stderr[-800:]}") + + # 7) 출력 검증 + if not os.path.isfile(out_win): + raise EncodeError("output_check", "output file not created") + output_bytes = os.path.getsize(out_win) + if output_bytes < MIN_OUTPUT_BYTES: + raise EncodeError("output_check", f"output too small: {output_bytes} bytes") + + return { + "ok": True, + "duration_ms": duration_ms, + "output_path_nas": output_path_nas, + "output_bytes": output_bytes, + "encoder": "h264_nvenc", + "preset": "p4", + } + + +def check_ffmpeg_nvenc() -> bool: + """서버 시작 시 NVENC 가용성 확인.""" + try: + result = subprocess.run( + [FFMPEG_PATH, "-encoders"], + capture_output=True, text=True, timeout=10, + ) + return "h264_nvenc" in result.stdout + except Exception: + return False +``` + +### Step 4: Run tests + +```bash +cd music_ai && python -m pytest tests/test_video_encoder.py -v +``` + +Expected: 6 PASS + +### Step 5: Commit + +```bash +cd C:/Users/jaeoh/Desktop/workspace/music_ai +git init 2>/dev/null || true # may not be a git repo, that's OK +# music_ai is local-only per CLAUDE.md, no remote push +``` + +(music_ai is local-only; just save the file. No git push needed.) + +--- + +## Task 2: Windows `music_ai/server.py` — `/encode_video` endpoint + 헬스 확장 + +**Files:** +- Modify: `music_ai/server.py` +- Modify: `music_ai/.env.example` + +### Step 1: Read existing server.py to understand FastAPI pattern + existing /health + +### Step 2: Add `/encode_video` endpoint + +```python +# server.py — 추가 +from pydantic import BaseModel +from fastapi import HTTPException +import video_encoder + + +class EncodeVideoRequest(BaseModel): + cover_path_nas: str + audio_path_nas: str + output_path_nas: str + resolution: str = "1920x1080" + duration_sec: int = 0 + style: str = "visualizer" + + +@app.post("/encode_video") +def encode_video_endpoint(req: EncodeVideoRequest): + try: + result = video_encoder.encode_video( + cover_path_nas=req.cover_path_nas, + audio_path_nas=req.audio_path_nas, + output_path_nas=req.output_path_nas, + resolution=req.resolution, + duration_sec=req.duration_sec, + style=req.style, + ) + return result + except video_encoder.EncodeError as e: + # input_validation, path_translate → 400 + # ffmpeg, output_check → 500 + status_code = 400 if e.stage in ("input_validation", "path_translate") else 500 + raise HTTPException( + status_code=status_code, + detail={"ok": False, "stage": e.stage, "error": e.message}, + ) +``` + +### Step 3: 확장된 `/health` + +기존 `/health` 응답에 추가: +```python +import torch # if existing health uses it +import video_encoder + +# Module-level cache so health doesn't run ffmpeg every call +_FFMPEG_NVENC_CACHED = None +def _ffmpeg_nvenc_available(): + global _FFMPEG_NVENC_CACHED + if _FFMPEG_NVENC_CACHED is None: + _FFMPEG_NVENC_CACHED = video_encoder.check_ffmpeg_nvenc() + return _FFMPEG_NVENC_CACHED + + +@app.get("/health") +def health(): + return { + "ok": True, + "gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, # 또는 기존 형식 유지 + "musicgen_loaded": True, # 기존 그대로 + "ffmpeg_path": video_encoder.FFMPEG_PATH, + "ffmpeg_nvenc": _ffmpeg_nvenc_available(), + } +``` + +(기존 `/health`의 정확한 형식은 코드 읽고 매칭. 위는 예시.) + +### Step 4: `.env.example` 업데이트 + +```env +# Existing +MODEL_NAME=facebook/musicgen-stereo-large +OUTPUT_DIR=output +SERVER_PORT=8765 + +# New for video encoder +NAS_VOLUME_PREFIX=/volume1/ +WINDOWS_DRIVE_ROOT=Z:\ +FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe +``` + +### Step 5: 수동 검증 + +```bash +cd music_ai && start.bat # 또는 적절한 시작 명령 +curl http://localhost:8765/health +# Expected: {..., "ffmpeg_nvenc": true} + +curl -X POST http://localhost:8765/encode_video -H "Content-Type: application/json" -d '{ + "cover_path_nas": "/volume1/docker/webpage/data/videos/3/cover.jpg", + "audio_path_nas": "/volume1/docker/webpage/data/1c695df3-8a82-4c09-ba7b-82c07608ec5b.mp3", + "output_path_nas": "/volume1/docker/webpage/data/videos/test/video.mp4", + "resolution": "1920x1080", + "duration_sec": 176 +}' +# Expected: 200 + duration_ms ~ 10-20초 +``` + +(실제 파일 경로는 사용자 환경에 맞게 조정) + +### Step 6: Commit (music_ai is local-only, no remote) + +--- + +## Task 3: NAS music-lab — `pipeline/video.py` 재작성 + 테스트 + +**Files:** +- Rewrite: `music-lab/app/pipeline/video.py` +- Rewrite: `music-lab/tests/test_video_thumb.py` (video 부분만) + +### Step 1: Replace failing tests + +```python +# music-lab/tests/test_video_thumb.py — video 관련 테스트 부분만 교체 +import pytest +import respx +import httpx +from httpx import Response +from app.pipeline import video, thumb, storage + + +@pytest.fixture +def encoder_env(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.mock +def test_generate_video_calls_remote_encoder(encoder_env, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path)) + 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/docker/webpage/data/videos/3/video.mp4", + "output_bytes": 28000000, + "encoder": "h264_nvenc", "preset": "p4", + }) + ) + out = video.generate( + pipeline_id=3, + audio_path="/app/data/1c695df3.mp3", + cover_path="/app/data/videos/3/cover.jpg", + genre="lo-fi", duration_sec=120, resolution="1920x1080", + style="visualizer", + ) + assert out["url"].endswith("/3/video.mp4") + assert out["used_fallback"] is False + assert out["encode_duration_ms"] == 12000 + + +@respx.mock +def test_generate_video_raises_on_connection_error(encoder_env, monkeypatch, tmp_path): + monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path)) + 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( + pipeline_id=4, + audio_path="/app/data/x.mp3", cover_path="/app/data/videos/4/cover.jpg", + genre="lo-fi", duration_sec=120, resolution="1920x1080", + ) + assert "연결 실패" in str(exc.value) or "Connection" in str(exc.value) + + +@respx.mock +def test_generate_video_raises_on_500(encoder_env, monkeypatch, tmp_path): + monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path)) + respx.post("http://192.168.45.59:8765/encode_video").mock( + return_value=Response(500, json={"ok": False, "stage": "ffmpeg", "error": "bad codec"}) + ) + with pytest.raises(video.VideoGenerationError) as exc: + video.generate( + pipeline_id=5, + audio_path="/app/data/x.mp3", cover_path="/app/data/videos/5/cover.jpg", + genre="lo-fi", duration_sec=120, resolution="1920x1080", + ) + assert "Windows 인코더 오류" in str(exc.value) + assert "ffmpeg" in str(exc.value) + + +def test_generate_video_no_url_configured(monkeypatch, tmp_path): + monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(video, "ENCODER_URL", "") + with pytest.raises(video.VideoGenerationError) as exc: + video.generate( + pipeline_id=6, + audio_path="/app/data/x.mp3", cover_path="/app/data/videos/6/cover.jpg", + genre="lo-fi", duration_sec=120, resolution="1920x1080", + ) + assert "WINDOWS_VIDEO_ENCODER_URL" in str(exc.value) + + +def test_container_to_nas_videos_path(monkeypatch): + monkeypatch.setenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos") + monkeypatch.setenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music") + assert video._container_to_nas("/app/data/videos/3/cover.jpg") == "/volume1/docker/webpage/data/videos/3/cover.jpg" + + +def test_container_to_nas_music_path(monkeypatch): + monkeypatch.setenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos") + monkeypatch.setenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music") + assert video._container_to_nas("/app/data/abc.mp3") == "/volume1/docker/webpage/data/music/abc.mp3" +``` + +기존 `test_generate_video_calls_ffmpeg`, `test_generate_video_failure_marks_failed` 삭제. thumb 관련 테스트는 그대로 유지. + +### Step 2: Run, verify fail + +```bash +cd music-lab && python -m pytest tests/test_video_thumb.py -v +``` + +Expected: video 관련 테스트들이 실패 (또는 ImportError). + +### Step 3: Rewrite `app/pipeline/video.py` + +```python +"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출. + +Windows 서버 다운/실패 시 즉시 예외 (NAS 로컬 폴백 없음 — 의도적 결정). +""" +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 + 마진 + +# NAS 호스트 절대경로 prefix — docker bind mount의 host 측 +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") + + +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 GPU 서버 호출. 다운/실패 시 즉시 예외.""" + 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 인코더 호출: pipeline=%d audio=%s", pipeline_id, audio_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, httpx.NetworkError) as e: + raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}") + + if resp.status_code != 200: + try: + detail = resp.json().get("detail", resp.json()) + except Exception: + detail = {"error": resp.text[:300]} + stage = detail.get("stage", "?") if isinstance(detail, dict) else "?" + error = detail.get("error", str(detail)) if isinstance(detail, dict) else str(detail) + raise VideoGenerationError( + f"Windows 인코더 오류 ({resp.status_code}): {stage} — {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 + """ + if container_path.startswith("/app/data/videos/"): + return container_path.replace("/app/data/videos/", NAS_VIDEOS_ROOT + "/", 1) + if container_path.startswith("/app/data/"): + rel = container_path[len("/app/data/"):] + return NAS_MUSIC_ROOT + "/" + rel + return container_path +``` + +### Step 4: Run tests + +```bash +cd music-lab && python -m pytest tests/ -v +``` + +Expected: 73 PASS — 2 (제거) + 6 (신규) = 77? 아니면 73 그대로 — count 확인. + +### Step 5: Commit + push + +```bash +git -C C:/Users/jaeoh/Desktop/workspace/web-backend add music-lab/app/pipeline/video.py \ + music-lab/tests/test_video_thumb.py +git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(music-lab): 영상 인코딩을 Windows GPU 서버로 오프로드 + +- pipeline/video.py 재작성: subprocess.run 제거, httpx로 192.168.45.59:8765/encode_video 호출 +- Windows 서버 다운 시 즉시 VideoGenerationError (NAS 로컬 폴백 X) +- /app/data/* → /volume1/docker/webpage/data/* 경로 변환 (_container_to_nas) +- 테스트는 respx mock 기반으로 교체 (6개 신규)" +git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main +``` + +--- + +## Task 4: docker-compose.yml env 추가 + +**Files:** +- Modify: `web-backend/docker-compose.yml` + +### Step 1: music-lab 서비스 environment에 추가 + +```yaml + music-lab: + environment: + # ... existing ... + - 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} +``` + +### Step 2: docker-compose syntax 검증 + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -c "import yaml; yaml.safe_load(open('docker-compose.yml'))" && echo OK +``` + +### Step 3: Commit + push + +```bash +git -C C:/Users/jaeoh/Desktop/workspace/web-backend add docker-compose.yml +git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "chore(infra): GPU 인코더 env 추가 (WINDOWS_VIDEO_ENCODER_URL)" +git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main +``` + +--- + +## Task 5: 사용자 매뉴얼 단계 (사람이 직접) + +후속 단계, 코드 작업 아님: + +1. **Windows PC: ffmpeg 설치 + PATH 설정** + - https://www.gyan.dev/ffmpeg/builds/ → "release full" 다운로드 + - `C:\ffmpeg\` 압축 해제 → `C:\ffmpeg\bin\ffmpeg.exe` 확인 + - 시스템 PATH에 `C:\ffmpeg\bin` 추가 + - 검증: `ffmpeg -version` + `ffmpeg -encoders | findstr h264_nvenc` + +2. **Windows PC: `music_ai/.env` 추가** + ```env + NAS_VOLUME_PREFIX=/volume1/ + WINDOWS_DRIVE_ROOT=Z:\ + FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe + ``` + +3. **Windows PC: SMB 마운트 확인** — `Z:\docker\webpage\data\` 접근 가능 + +4. **Windows PC: `music_ai` 서버 재시작** — `start.bat` + +5. **Windows PC 헬스 체크** — `curl http://localhost:8765/health` → `ffmpeg_nvenc: true` 확인 + +6. **NAS `.env`에 추가** + ```env + WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765 + ``` + +7. **NAS music-lab 재시작** — `docker compose up -d music-lab` + +8. **E2E 테스트** — 진행 탭에서 새 파이프라인 시작, 영상 단계가 10–20초에 완료되는지 확인 + +--- + +## Self-Review + +**Spec coverage:** +- §4 Windows endpoint → Task 1, 2 ✓ +- §5 NAS video.py → Task 3 ✓ +- §6 에러 처리 → Task 3 (httpx 예외 catch) ✓ +- §7 헬스 모니터링 → Task 2 (`/health` 확장) ✓ +- §8 테스트 → Task 1, 3 ✓ +- §9 Windows 사전 준비 → Task 5 (사용자 수동) ✓ +- §10 산출물 → 4 task로 모두 커버 + +**Placeholder scan:** 없음. + +**Type consistency:** +- `EncodeError(stage, message)` Task 1 정의, Task 2에서 `e.stage`/`e.message` 사용 ✓ +- `VideoGenerationError` Task 3에서 raise, 기존 orchestrator에서 catch ✓ +- 응답 JSON 형식 spec §4-2와 일치 ✓ +- 환경변수 이름 일관 (`NAS_VOLUME_PREFIX`, `WINDOWS_DRIVE_ROOT`, `FFMPEG_PATH`, `WINDOWS_VIDEO_ENCODER_URL`, `NAS_VIDEOS_ROOT`, `NAS_MUSIC_ROOT`) + +--- diff --git a/docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md b/docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md new file mode 100644 index 0000000..a2d1cee --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md @@ -0,0 +1,486 @@ +# 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 인코딩 프로파일 + +---