from __future__ import annotations import logging from contextlib import asynccontextmanager from typing import AsyncGenerator from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse, JSONResponse from app.config import settings from app.models import JobSubmitResponse, JobStatusResponse from app.service import get_service logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator: logger.info("Starting Revids API...") await get_service() yield logger.info("Shutting down Revids API.") app = FastAPI( title=settings.app_name, version="0.1.0", lifespan=lifespan, ) @app.get("/health") async def health() -> JSONResponse: import torch gpu_available = torch.cuda.is_available() return JSONResponse({ "status": "ok", "gpu": gpu_available, "gpu_name": torch.cuda.get_device_name(0) if gpu_available else None, }) @app.post("/generate", status_code=201) async def generate( image: UploadFile = File(..., description="Input image (PNG, JPG, etc.)"), prompt: str | None = Form(None), width: int = Form(768), height: int = Form(512), num_frames: int = Form(65), fps: float = Form(24.0), seed: int | None = Form(None), ) -> JobSubmitResponse: from PIL import Image img_bytes = await image.read() pil_image = Image.open(img_bytes).convert("RGB") validate_frame_count(num_frames) svc = await get_service() job = await svc.submit_job(pil_image, { "prompt": prompt, "width": width, "height": height, "num_frames": num_frames, "fps": fps, "seed": seed, }) return JobSubmitResponse(job_id=job.id) @app.get("/jobs/{job_id}") async def get_job(job_id: str) -> JobStatusResponse: svc = await get_service() job = await svc.get_job(job_id) if job is None: raise HTTPException(status_code=404, detail="Job not found") return JobStatusResponse( job_id=job.id, status=job.status, prompt=job.prompt, params=job.params, error=job.error, created_at=job.created_at, completed_at=job.completed_at, ) @app.get("/jobs/{job_id}/download") async def download_video(job_id: str) -> FileResponse: svc = await get_service() job = await svc.get_job(job_id) if job is None: raise HTTPException(status_code=404, detail="Job not found") if job.status != "completed": raise HTTPException(status_code=404, detail="Video not ready") video_path = await svc.get_video_path(job_id) if video_path is None: raise HTTPException(status_code=404, detail="Video file missing") return FileResponse(video_path, media_type="video/mp4", filename=f"{job_id}.mp4") @app.delete("/jobs/{job_id}") async def delete_job(job_id: str) -> dict: svc = await get_service() job = await svc.get_job(job_id) if job is None: raise HTTPException(status_code=404, detail="Job not found") deleted, _ = await svc.delete_job(job_id) if not deleted: raise HTTPException(status_code=404, detail="Job not found") return {"deleted": job_id} def validate_frame_count(num_frames: int) -> None: valid = {8 * n + 1 for n in range(1, 129)} if num_frames not in valid: candidates = sorted(f for f in valid if abs(f - num_frames) <= 8) raise HTTPException( status_code=400, detail=f"num_frames must be 8n+1. Got {num_frames}. Closest valid: {candidates[:4]}", ) @app.exception_handler(HTTPException) async def http_exception_handler(_, exc: HTTPException) -> JSONResponse: return JSONResponse( status_code=exc.status_code, content={"error": exc.detail}, )