Compare commits
9 Commits
6f505b8cb1
...
4b28ef3afa
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b28ef3afa | |||
| 211aff1e45 | |||
| 37ca8e594e | |||
| c9a094969d | |||
| e8dbf8092a | |||
| 21cf0114f4 | |||
| 20f83cee33 | |||
| 1e77123394 | |||
| fbd8d26ec6 |
@@ -90,6 +90,29 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
video-lab:
|
||||
build:
|
||||
context: ./video-lab
|
||||
container_name: video-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18801:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- VIDEO_DATA_DIR=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/video:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
insta-lab:
|
||||
build:
|
||||
context: ./insta-lab
|
||||
@@ -265,6 +288,7 @@ services:
|
||||
- personal
|
||||
- packs-lab
|
||||
- travel-proxy
|
||||
- video-lab
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
@@ -274,6 +298,7 @@ services:
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||
- ${RUNTIME_PATH}/data/music:/data/music:ro
|
||||
- ${RUNTIME_PATH}/data/videos:/data/videos:ro
|
||||
- ${RUNTIME_PATH}/data/video:/data/video:ro
|
||||
- ${RUNTIME_PATH}/data/insta/insta_cards:/data/insta_cards:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
2573
docs/superpowers/plans/2026-05-19-plan-b-video-render.md
Normal file
2573
docs/superpowers/plans/2026-05-19-plan-b-video-render.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,17 @@ server {
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
# video media — Nginx 직접 mp4 서빙
|
||||
location ^~ /media/video/ {
|
||||
alias /data/video/;
|
||||
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, max-age=86400" always;
|
||||
add_header Accept-Ranges bytes always;
|
||||
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
# music videos — Nginx가 직접 비디오 파일 서빙
|
||||
location ^~ /media/insta/ {
|
||||
alias /data/insta_cards/;
|
||||
@@ -68,6 +79,21 @@ server {
|
||||
proxy_pass http://$music_backend$request_uri;
|
||||
}
|
||||
|
||||
# video-lab — 영상 생성 gateway
|
||||
location /api/video/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $video_backend video-lab:8000;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_pass http://$video_backend$request_uri;
|
||||
|
||||
proxy_read_timeout 120s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
# realestate API
|
||||
location /api/realestate/ {
|
||||
proxy_http_version 1.1;
|
||||
@@ -230,6 +256,26 @@ server {
|
||||
proxy_pass http://$music_internal_backend$request_uri;
|
||||
}
|
||||
|
||||
# Plan-B-Video — Windows video-render → NAS video-lab internal webhook
|
||||
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
|
||||
# Layer 3: X-Internal-Key (FastAPI dependency)
|
||||
location /api/internal/video/ {
|
||||
allow 192.168.45.0/24; # LAN 화이트리스트
|
||||
allow 100.64.0.0/10; # Tailscale CGNAT
|
||||
allow 127.0.0.1; # NAS 내부
|
||||
deny all;
|
||||
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $video_internal_backend video-lab:8000;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Internal-Key $http_x_internal_key;
|
||||
proxy_pass http://$video_internal_backend$request_uri;
|
||||
}
|
||||
|
||||
# portfolio API (Stock) — trailing slash 유무 모두 매칭
|
||||
location /api/portfolio {
|
||||
proxy_http_version 1.1;
|
||||
|
||||
10
video-lab/Dockerfile
Normal file
10
video-lab/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-alpine
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
0
video-lab/app/__init__.py
Normal file
0
video-lab/app/__init__.py
Normal file
17
video-lab/app/auth.py
Normal file
17
video-lab/app/auth.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""SP-8 — Windows worker → NAS video-lab internal webhook 인증.
|
||||
|
||||
X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교.
|
||||
서버 측 키 미설정 시 401 (안전한 기본값).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
|
||||
def verify_internal_key(x_internal_key: str = Header(...)):
|
||||
expected = os.getenv("INTERNAL_API_KEY")
|
||||
if not expected:
|
||||
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
|
||||
if x_internal_key != expected:
|
||||
raise HTTPException(401, "Invalid X-Internal-Key")
|
||||
95
video-lab/app/db.py
Normal file
95
video-lab/app/db.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""SQLite persistence for video_tasks. Single table — task 단위 추적만."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
DB_PATH = os.path.join(os.getenv("VIDEO_DATA_DIR", "/app/data"), "video.db")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _conn():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS video_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'queued',
|
||||
progress INTEGER DEFAULT 0,
|
||||
message TEXT DEFAULT '',
|
||||
video_url TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _row_to_dict(row) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"provider": row["provider"],
|
||||
"params": row["params"],
|
||||
"status": row["status"],
|
||||
"progress": row["progress"],
|
||||
"message": row["message"],
|
||||
"video_url": row["video_url"],
|
||||
"error": row["error"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def create_task(task_id: str, provider: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO video_tasks (id, provider, params) VALUES (?, ?, ?)",
|
||||
(task_id, provider, json.dumps(params)),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM video_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def update_task(
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: int,
|
||||
message: str = "",
|
||||
video_url: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE video_tasks
|
||||
SET status = ?, progress = ?, message = ?, video_url = ?, error = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, progress, message, video_url, error, task_id),
|
||||
)
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM video_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
52
video-lab/app/internal_router.py
Normal file
52
video-lab/app/internal_router.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""SP-8 — Windows video-render → NAS video-lab internal webhook.
|
||||
|
||||
POST /api/internal/video/update
|
||||
- X-Internal-Key 인증 필수
|
||||
- video_tasks row update (status, progress, message, video_url, error)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import db
|
||||
from .auth import verify_internal_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UpdatePayload(BaseModel):
|
||||
task_id: str
|
||||
status: str = Field(..., description="processing|succeeded|failed")
|
||||
progress: int = Field(..., ge=0, le=100)
|
||||
message: str = ""
|
||||
video_url: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/internal/video/update",
|
||||
dependencies=[Depends(verify_internal_key)],
|
||||
)
|
||||
def video_update(payload: UpdatePayload):
|
||||
task = db.get_task(payload.task_id)
|
||||
if task is None:
|
||||
raise HTTPException(404, f"task not found: {payload.task_id}")
|
||||
|
||||
db.update_task(
|
||||
payload.task_id,
|
||||
payload.status,
|
||||
payload.progress,
|
||||
message=payload.message,
|
||||
video_url=payload.video_url,
|
||||
error=payload.error,
|
||||
)
|
||||
logger.info(
|
||||
"internal/video/update task=%s status=%s progress=%d",
|
||||
payload.task_id, payload.status, payload.progress,
|
||||
)
|
||||
return {"ok": True}
|
||||
117
video-lab/app/main.py
Normal file
117
video-lab/app/main.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""FastAPI entrypoint for video-lab.
|
||||
|
||||
POST /api/video/generate — provider + params → Redis push → task_id 반환
|
||||
GET /api/video/tasks/{id} — DB 조회
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import db
|
||||
from .internal_router import router as internal_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CORS_ALLOW_ORIGINS = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080")
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
|
||||
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||
|
||||
SUPPORTED_PROVIDERS = {"sora", "veo", "kling", "seedance"}
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(internal_router)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in CORS_ALLOW_ORIGINS.split(",")],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
db.init_db()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "video-lab"}
|
||||
|
||||
|
||||
@app.get("/api/video/providers")
|
||||
def list_providers():
|
||||
"""4 provider 항상 노출 (key 누락은 worker가 failed 보고)."""
|
||||
return {"providers": [
|
||||
{"id": "sora", "name": "Sora 2", "models": ["sora-2", "sora-2-pro"],
|
||||
"durations": [8, 16, 20], "sizes": ["1280x720", "1920x1080", "1080x1920", "848x480", "480x848"]},
|
||||
{"id": "veo", "name": "Veo 3.1", "models": ["veo-3.1-generate-001", "veo-3.1-fast-generate-001"],
|
||||
"durations": [4, 6, 8], "aspect_ratios": ["16:9", "9:16"]},
|
||||
{"id": "kling", "name": "Kling", "models": ["1.5", "1.6", "2.1", "2.1-master", "2.5", "2.6"],
|
||||
"durations": [5, 10], "aspect_ratios": ["16:9", "9:16", "1:1"]},
|
||||
{"id": "seedance", "name": "Seedance 2.0", "models": ["seedance-2.0"],
|
||||
"durations": [4, 5, 6, 8, 10, 12, 15], "aspect_ratios": ["16:9", "9:16", "1:1", "4:3"]},
|
||||
]}
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
provider: str = Field(..., description="sora|veo|kling|seedance")
|
||||
model: Optional[str] = None
|
||||
prompt: str
|
||||
# Optional 공통
|
||||
duration: Optional[int] = None
|
||||
aspect_ratio: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
negative_prompt: Optional[str] = None
|
||||
# Provider 별 추가 키는 extra 허용
|
||||
extra: Optional[Dict[str, Any]] = None
|
||||
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
|
||||
async def _push_render_job(task_id: str, job_type: str, params: dict) -> None:
|
||||
"""Redis queue:video-render에 push."""
|
||||
kst = timezone(timedelta(hours=9))
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"kind": "video",
|
||||
"job_type": job_type,
|
||||
"params": params,
|
||||
"submitted_at": datetime.now(kst).isoformat(),
|
||||
}
|
||||
await redis_client.rpush("queue:video-render", json.dumps(payload))
|
||||
|
||||
|
||||
@app.post("/api/video/generate")
|
||||
async def generate_video(req: GenerateRequest):
|
||||
"""영상 생성 — Redis 큐로 Windows video-render에 위임."""
|
||||
if req.provider not in SUPPORTED_PROVIDERS:
|
||||
raise HTTPException(400, f"지원하지 않는 provider: {req.provider} (supported: {sorted(SUPPORTED_PROVIDERS)})")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump(exclude_none=True)
|
||||
db.create_task(task_id, req.provider, params)
|
||||
|
||||
job_type = f"{req.provider}_generation" # sora_generation, veo_generation, kling_generation, seedance_generation
|
||||
await _push_render_job(task_id, job_type, params)
|
||||
return {"task_id": task_id, "provider": req.provider}
|
||||
|
||||
|
||||
@app.get("/api/video/tasks/{task_id}")
|
||||
def get_task_status(task_id: str):
|
||||
t = db.get_task(task_id)
|
||||
if not t:
|
||||
raise HTTPException(404, "task not found")
|
||||
return t
|
||||
6
video-lab/requirements.txt
Normal file
6
video-lab/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.30.6
|
||||
redis>=5.0
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.21
|
||||
httpx>=0.27.0
|
||||
0
video-lab/tests/__init__.py
Normal file
0
video-lab/tests/__init__.py
Normal file
23
video-lab/tests/test_auth.py
Normal file
23
video-lab/tests/test_auth.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""verify_internal_key — Windows video-render webhook 인증."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from app.auth import verify_internal_key
|
||||
|
||||
|
||||
def test_valid_key_passes(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
|
||||
verify_internal_key(x_internal_key="secret123")
|
||||
|
||||
|
||||
def test_invalid_key_raises_401(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_internal_key(x_internal_key="wrong")
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_missing_env_key_raises_401(monkeypatch):
|
||||
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_internal_key(x_internal_key="any")
|
||||
assert exc.value.status_code == 401
|
||||
89
video-lab/tests/test_internal_router.py
Normal file
89
video-lab/tests/test_internal_router.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""POST /api/internal/video/update — Windows video-render webhook."""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from app.internal_router import router
|
||||
from app import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_key(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("VIDEO_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "test_video.db"))
|
||||
db.init_db()
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_task():
|
||||
tid = "video-task-1"
|
||||
db.create_task(tid, "sora", {"prompt": "test"})
|
||||
return tid
|
||||
|
||||
|
||||
def test_update_with_valid_key_updates_db(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/video/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={"task_id": tid, "status": "processing", "progress": 30, "message": "downloading"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "processing"
|
||||
assert task["progress"] == 30
|
||||
assert task["message"] == "downloading"
|
||||
|
||||
|
||||
def test_update_with_invalid_key_returns_401(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/video/update",
|
||||
headers={"X-Internal-Key": "wrong"},
|
||||
json={"task_id": tid, "status": "processing", "progress": 30},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_update_succeeded_with_video_url(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/video/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={
|
||||
"task_id": tid, "status": "succeeded", "progress": 100,
|
||||
"message": "완료", "video_url": "/media/video/video-task-1.mp4",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "succeeded"
|
||||
assert task["video_url"] == "/media/video/video-task-1.mp4"
|
||||
|
||||
|
||||
def test_update_failed_records_error(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/video/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={"task_id": tid, "status": "failed", "progress": 0, "error": "Sora API rate limit"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "failed"
|
||||
assert "Sora" in (task.get("error") or "")
|
||||
|
||||
|
||||
def test_update_unknown_task_returns_404(client):
|
||||
r = client.post(
|
||||
"/api/internal/video/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={"task_id": "nonexistent", "status": "processing", "progress": 10},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
Reference in New Issue
Block a user