diff --git a/services/video-render/.env.example b/services/video-render/.env.example new file mode 100644 index 0000000..c1784cd --- /dev/null +++ b/services/video-render/.env.example @@ -0,0 +1,29 @@ +# Plan-B-Video — Windows video-render worker + +# NAS Redis 큐 +REDIS_URL=redis://192.168.45.54:6379 + +# NAS internal webhook (video-lab port 18801) +NAS_BASE_URL=http://192.168.45.54:18801 +INTERNAL_API_KEY=__copy_from_nas_dotenv__ + +# Sora 2 (OpenAI) +OPENAI_API_KEY=__paste_openai_key__ + +# Veo 3.1 (Google Vertex AI) +GOOGLE_PROJECT_ID=__paste_gcp_project_id__ +GOOGLE_LOCATION=us-central1 +GOOGLE_GCS_BUCKET=__paste_gcs_bucket_name__ +GOOGLE_APPLICATION_CREDENTIALS=/app/keys/gcp-sa.json + +# Kling (PiAPI gateway) +PIAPI_API_KEY=__paste_piapi_key__ + +# Seedance 2.0 (BytePlus) +SEEDANCE_API_KEY=__paste_seedance_key__ + +# NAS SMB mount 안의 video 디렉토리 +VIDEO_MEDIA_ROOT=/mnt/nas/webpage/data/video + +# nginx 서빙 prefix (NAS webhook payload용) +VIDEO_MEDIA_URL_PREFIX=/media/video diff --git a/services/video-render/Dockerfile b/services/video-render/Dockerfile new file mode 100644 index 0000000..ab72368 --- /dev/null +++ b/services/video-render/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim-bookworm +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt + +COPY . . + +EXPOSE 8000 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/services/video-render/nas_client.py b/services/video-render/nas_client.py new file mode 100644 index 0000000..aec9dc0 --- /dev/null +++ b/services/video-render/nas_client.py @@ -0,0 +1,54 @@ +"""NAS webhook 어댑터 — Windows worker가 NAS DB 직접 접근 못하므로 HTTP로 위임. + +Plan-B-Music nas_client와 동일 패턴 (call-time os.getenv으로 테스트 격리). +""" +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +logger = logging.getLogger(__name__) + +_TIMEOUT = 10.0 + + +def _post(payload: Dict[str, Any]) -> None: + nas_base_url = os.getenv("NAS_BASE_URL", "http://192.168.45.54:18801") + internal_api_key = os.getenv("INTERNAL_API_KEY", "") + url = f"{nas_base_url}/api/internal/video/update" + try: + r = httpx.post( + url, + headers={"X-Internal-Key": internal_api_key}, + json=payload, + timeout=_TIMEOUT, + ) + if r.status_code != 200: + logger.error("webhook %s returned %d: %s", + payload.get("task_id"), r.status_code, r.text[:200]) + except Exception: + logger.exception("webhook %s 호출 실패", payload.get("task_id")) + + +def webhook_update_task( + task_id: str, + status: str, + progress: int, + message: str = "", + video_url: Optional[str] = None, + error: Optional[str] = None, +) -> None: + payload: Dict[str, Any] = { + "task_id": task_id, + "status": status, + "progress": progress, + "message": message, + } + if video_url is not None: + payload["video_url"] = video_url + if error is not None: + payload["error"] = error + _post(payload) diff --git a/services/video-render/requirements.txt b/services/video-render/requirements.txt new file mode 100644 index 0000000..4bd2877 --- /dev/null +++ b/services/video-render/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +requests==2.32.3 +redis>=5.0 +httpx>=0.27 +openai>=1.50.0 +google-cloud-storage>=2.18.0 +pytest>=8.0 +pytest-asyncio>=0.24 +respx>=0.21 diff --git a/services/video-render/tests/__init__.py b/services/video-render/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/video-render/tests/test_nas_client.py b/services/video-render/tests/test_nas_client.py new file mode 100644 index 0000000..e28fc03 --- /dev/null +++ b/services/video-render/tests/test_nas_client.py @@ -0,0 +1,70 @@ +"""nas_client — webhook adapter for video-render.""" +import pytest +import respx +import httpx + +from nas_client import webhook_update_task + + +@pytest.fixture(autouse=True) +def _env(monkeypatch): + monkeypatch.setenv("NAS_BASE_URL", "http://nas-test:18801") + monkeypatch.setenv("INTERNAL_API_KEY", "test-key") + + +@respx.mock +def test_webhook_update_task_sends_x_internal_key(): + route = respx.post("http://nas-test:18801/api/internal/video/update").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + webhook_update_task("task-1", "processing", 30, message="downloading") + assert route.called + req = route.calls[0].request + assert req.headers["X-Internal-Key"] == "test-key" + import json + body = json.loads(req.content) + assert body["task_id"] == "task-1" + assert body["status"] == "processing" + assert body["progress"] == 30 + + +@respx.mock +def test_webhook_update_task_with_video_url(): + route = respx.post("http://nas-test:18801/api/internal/video/update").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + webhook_update_task("task-2", "succeeded", 100, message="완료", + video_url="/media/video/task-2.mp4") + import json + payload = json.loads(route.calls[0].request.content) + assert payload["video_url"] == "/media/video/task-2.mp4" + assert payload["status"] == "succeeded" + + +@respx.mock +def test_webhook_update_task_with_error(): + route = respx.post("http://nas-test:18801/api/internal/video/update").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + webhook_update_task("task-3", "failed", 0, error="Sora API rate limit") + import json + payload = json.loads(route.calls[0].request.content) + assert payload["error"] == "Sora API rate limit" + + +@respx.mock +def test_webhook_swallows_network_error(caplog): + respx.post("http://nas-test:18801/api/internal/video/update").mock( + side_effect=httpx.ConnectError("no host") + ) + webhook_update_task("task-5", "processing", 10) + assert "task-5" in caplog.text + + +@respx.mock +def test_webhook_swallows_non_200(caplog): + respx.post("http://nas-test:18801/api/internal/video/update").mock( + return_value=httpx.Response(500, text="server error") + ) + webhook_update_task("task-6", "processing", 50) + assert "task-6" in caplog.text