From f32792e4a9984d415e10c15d38f1a50e0e610f8d Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 19 May 2026 08:35:20 +0900 Subject: [PATCH] feat(video-render): scaffold + nas_client webhook adapter (SP-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerfile (python:3.12-slim), requirements (openai + google-cloud-storage + httpx + redis). .env.example: OPENAI/GOOGLE/PIAPI/SEEDANCE keys + VIDEO_MEDIA_ROOT. nas_client.webhook_update_task: call-time os.getenv (테스트 격리), respx mock 5 tests. Plan-B-Video Phase 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- services/video-render/.env.example | 29 ++++++++ services/video-render/Dockerfile | 16 +++++ services/video-render/nas_client.py | 54 ++++++++++++++ services/video-render/requirements.txt | 10 +++ services/video-render/tests/__init__.py | 0 .../video-render/tests/test_nas_client.py | 70 +++++++++++++++++++ 6 files changed, 179 insertions(+) create mode 100644 services/video-render/.env.example create mode 100644 services/video-render/Dockerfile create mode 100644 services/video-render/nas_client.py create mode 100644 services/video-render/requirements.txt create mode 100644 services/video-render/tests/__init__.py create mode 100644 services/video-render/tests/test_nas_client.py 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