diff --git a/video-lab/app/main.py b/video-lab/app/main.py new file mode 100644 index 0000000..fe75d55 --- /dev/null +++ b/video-lab/app/main.py @@ -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