9 Commits

Author SHA1 Message Date
4b28ef3afa feat(nginx): /api/internal/video/ 3-layer 차단 (SP-8)
LAN(192.168.45.0/24) + Tailscale(100.64.0.0/10) + 127.0.0.1 allow.
deny all. X-Internal-Key forward → video-lab:8000.
insta/music 블록과 동일 패턴.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:33:37 +09:00
211aff1e45 docs(plan): Plan-B-Video port 18800 → 18801 (realestate-lab 충돌)
T6 implementer가 발견: realestate-lab이 이미 18800 점유.
video-lab 포트를 18801로 정정. plan 18 occurrence 일괄 변경.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:32:56 +09:00
37ca8e594e feat(video-lab): docker-compose entry + nginx routing (SP-8)
video-lab service: port 18801, REDIS_URL/INTERNAL_API_KEY env,
depends_on redis, /app/data volume mount.
nginx: /api/video/ proxy + /media/video/ direct serve alias.
frontend depends_on + volume mount 추가.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:31:37 +09:00
c9a094969d feat(video-lab): main.py — FastAPI + redis client + 2 endpoint (SP-8)
POST /api/video/generate (provider validation + Redis push + task_id 반환).
GET /api/video/tasks/{id} (DB 조회).
GET /api/video/providers (4 provider 메타).
SUPPORTED_PROVIDERS = sora/veo/kling/seedance.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:30:21 +09:00
e8dbf8092a feat(video-lab): /api/internal/video/update endpoint + tests (SP-8)
UpdatePayload schema (task_id/status/progress/message/video_url/error).
404 if task not found. insta/music-lab과 동일 패턴 + video_url 필드.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:29:05 +09:00
21cf0114f4 feat(video-lab): verify_internal_key + tests (SP-8)
X-Internal-Key 검증 dependency. insta-lab/music-lab 동일 패턴.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:27:38 +09:00
20f83cee33 feat(video-lab): app/db.py — video_tasks 테이블 + CRUD (SP-8)
WAL + busy_timeout 표준 fix. create_task / update_task / get_task.
provider 컬럼 추가(Sora/Veo/Kling/Seedance 구분). video_url 필드.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:26:19 +09:00
1e77123394 feat(video-lab): Dockerfile + requirements + app package skeleton (SP-8)
NAS video-lab 신설. python:3.12-alpine 기반. redis>=5.0 의존성.
영상 외부 호출 없음(gateway만) — 외부 API 의존 없음.
Plan-B-Video Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:24:50 +09:00
fbd8d26ec6 docs(plan): Plan-B-Video — video-lab 신설 + 4 provider Windows worker
SP-7 + SP-8 — 4 video provider (Sora 2, Veo 3.1, Kling via PiAPI, Seedance 2.0)
Windows video-render로 분산. NAS video-lab 신설 (port 18800).
spec §10 SP-7 갱신: 6 provider(Runway/Pika/Luma 포함) → 4 provider 축소
(박재오 2026-05-19 결정 — 실사용 provider만).

17 task: NAS video-lab 신설(1~6) → nginx 차단(7) → Windows video-render(8~14)
→ NAS push + 박재오 빌드(15) → Kling end-to-end(16) → 메모리 기록(17).

부록: 4 provider API 키 발급 가이드 (Sora/Veo/Kling/Seedance).
Plan-B-Music 3가지 함정 (WSL2 mirror + Redis chown + .env NAS_BASE_URL) 모두 사전 인지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:22:20 +09:00
13 changed files with 3053 additions and 0 deletions

View File

@@ -90,6 +90,29 @@ services:
timeout: 5s timeout: 5s
retries: 3 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: insta-lab:
build: build:
context: ./insta-lab context: ./insta-lab
@@ -265,6 +288,7 @@ services:
- personal - personal
- packs-lab - packs-lab
- travel-proxy - travel-proxy
- video-lab
ports: ports:
- "8080:80" - "8080:80"
volumes: volumes:
@@ -274,6 +298,7 @@ services:
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro - ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
- ${RUNTIME_PATH}/data/music:/data/music:ro - ${RUNTIME_PATH}/data/music:/data/music:ro
- ${RUNTIME_PATH}/data/videos:/data/videos: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 - ${RUNTIME_PATH}/data/insta/insta_cards:/data/insta_cards:ro
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,17 @@ server {
autoindex off; 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가 직접 비디오 파일 서빙 # music videos — Nginx가 직접 비디오 파일 서빙
location ^~ /media/insta/ { location ^~ /media/insta/ {
alias /data/insta_cards/; alias /data/insta_cards/;
@@ -68,6 +79,21 @@ server {
proxy_pass http://$music_backend$request_uri; 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 # realestate API
location /api/realestate/ { location /api/realestate/ {
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -230,6 +256,26 @@ server {
proxy_pass http://$music_internal_backend$request_uri; 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 유무 모두 매칭 # portfolio API (Stock) — trailing slash 유무 모두 매칭
location /api/portfolio { location /api/portfolio {
proxy_http_version 1.1; proxy_http_version 1.1;

10
video-lab/Dockerfile Normal file
View 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"]

View File

17
video-lab/app/auth.py Normal file
View 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
View 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

View 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
View 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

View 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

View File

View 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

View 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