Add video generation API with job tracking and model loading

This commit is contained in:
2026-06-03 18:58:11 -04:00
parent bd0cd4f9ff
commit ab580a2004
11 changed files with 388 additions and 68 deletions
+38 -16
View File
@@ -1,19 +1,24 @@
from __future__ import annotations
import logging
import re
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from pydantic import ValidationError
from fastapi.responses import FileResponse, JSONResponse
from app.config import settings
from app.models import JobSubmitResponse, JobStatusResponse
from app.models import GenerateForm, JobSubmitResponse, JobStatusResponse
from app.service import get_service
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
JOB_ID_RE = re.compile(r"[a-f0-9]{12}")
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator:
@@ -44,7 +49,7 @@ async def health() -> JSONResponse:
@app.post("/generate", status_code=201)
async def generate(
image: UploadFile = File(..., description="Input image (PNG, JPG, etc.)"),
image: UploadFile = File(...),
prompt: str | None = Form(None),
width: int = Form(768),
height: int = Form(512),
@@ -54,10 +59,25 @@ async def generate(
) -> JobSubmitResponse:
from PIL import Image
img_bytes = await image.read()
pil_image = Image.open(img_bytes).convert("RGB")
if image.content_length and image.content_length > MAX_IMAGE_SIZE:
raise HTTPException(status_code=400, detail="Image file must be under 10MB")
validate_frame_count(num_frames)
try:
GenerateForm(
prompt=prompt,
width=width,
height=height,
num_frames=num_frames,
fps=fps,
seed=seed,
)
except ValidationError as e:
raise HTTPException(status_code=422, detail=str(e))
img_bytes = await image.read()
if len(img_bytes) > MAX_IMAGE_SIZE:
raise HTTPException(status_code=400, detail="Image file must be under 10MB")
pil_image = Image.open(img_bytes).convert("RGB")
svc = await get_service()
job = await svc.submit_job(pil_image, {
@@ -71,8 +91,18 @@ async def generate(
return JobSubmitResponse(job_id=job.id)
def _resolve_job_id(job_id: str) -> str:
if not JOB_ID_RE.fullmatch(job_id):
raise HTTPException(
status_code=400,
detail="Invalid job_id format. Expected 12 lowercase hex characters.",
)
return job_id
@app.get("/jobs/{job_id}")
async def get_job(job_id: str) -> JobStatusResponse:
_resolve_job_id(job_id)
svc = await get_service()
job = await svc.get_job(job_id)
if job is None:
@@ -90,12 +120,13 @@ async def get_job(job_id: str) -> JobStatusResponse:
@app.get("/jobs/{job_id}/download")
async def download_video(job_id: str) -> FileResponse:
_resolve_job_id(job_id)
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")
raise HTTPException(status_code=503, detail="Video not ready")
video_path = await svc.get_video_path(job_id)
if video_path is None:
@@ -105,6 +136,7 @@ async def download_video(job_id: str) -> FileResponse:
@app.delete("/jobs/{job_id}")
async def delete_job(job_id: str) -> dict:
_resolve_job_id(job_id)
svc = await get_service()
job = await svc.get_job(job_id)
if job is None:
@@ -115,16 +147,6 @@ async def delete_job(job_id: str) -> dict:
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(