374 lines
13 KiB
Python
374 lines
13 KiB
Python
# Adapted from SGLang
|
|
# (https://github.com/sgl-project/sglang/blob/main/python/sglang/multimodal_gen/runtime/entrypoints/openai/video_api.py)
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import time
|
|
from typing import Any
|
|
|
|
from fastapi import (
|
|
APIRouter,
|
|
File,
|
|
Form,
|
|
HTTPException,
|
|
Path,
|
|
Query,
|
|
Request,
|
|
UploadFile,
|
|
)
|
|
from fastapi.responses import FileResponse
|
|
|
|
from fastvideo.api.compat import explicit_request_updates
|
|
from fastvideo.api.schema import GenerationRequest
|
|
from fastvideo.entrypoints.openai.state import (
|
|
get_default_request,
|
|
get_generator,
|
|
get_output_dir,
|
|
get_server_args,
|
|
)
|
|
from fastvideo.entrypoints.openai.protocol import (
|
|
VideoGenerationsRequest,
|
|
VideoListResponse,
|
|
VideoResponse,
|
|
generate_request_id,
|
|
)
|
|
from fastvideo.entrypoints.openai.stores import VIDEO_STORE
|
|
from fastvideo.entrypoints.openai.utils import (
|
|
merge_image_input_list,
|
|
parse_size,
|
|
save_image_to_path,
|
|
)
|
|
from fastvideo.logger import init_logger
|
|
|
|
logger = init_logger(__name__)
|
|
router = APIRouter(prefix="/v1/videos", tags=["videos"])
|
|
|
|
|
|
def _build_generation_kwargs(
|
|
request_id: str,
|
|
req: VideoGenerationsRequest,
|
|
default_request: GenerationRequest | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build a flat kwargs dict for ``generator.generate_video``.
|
|
|
|
Precedence (highest to lowest):
|
|
1. Request body — only fields the client explicitly sent
|
|
(``req.model_fields_set``, Pydantic v2).
|
|
2. ``default_request`` — only fields the operator explicitly set in
|
|
the serve YAML, projected via ``explicit_request_updates``. Schema
|
|
defaults on the dataclass are *not* treated as defaults here.
|
|
3. Hardcoded fallback (e.g. ``fps=24`` when neither side set it).
|
|
|
|
Why gate on ``model_fields_set`` / explicit paths? Both the request
|
|
Pydantic model and the ``GenerationRequest`` dataclass carry schema
|
|
defaults (e.g. ``seed=1024``, ``num_frames=125``). Without the gate
|
|
those would masquerade as intent and shadow the other side — the
|
|
gate preserves "operator pinned it" vs. "dataclass happened to have
|
|
that default."
|
|
"""
|
|
kwargs: dict[str, Any] = {}
|
|
if default_request is not None:
|
|
kwargs.update(explicit_request_updates(default_request))
|
|
|
|
body_set = req.model_fields_set
|
|
kwargs["prompt"] = req.prompt
|
|
|
|
if "size" in body_set and req.size:
|
|
w, h = parse_size(req.size)
|
|
if w is not None and h is not None:
|
|
kwargs["width"] = w
|
|
kwargs["height"] = h
|
|
|
|
if "fps" in body_set and req.fps is not None:
|
|
kwargs["fps"] = req.fps
|
|
|
|
if "num_frames" in body_set and req.num_frames is not None:
|
|
kwargs["num_frames"] = req.num_frames
|
|
elif "seconds" in body_set and req.seconds is not None:
|
|
fps = kwargs.get("fps", 24)
|
|
kwargs["num_frames"] = fps * req.seconds
|
|
|
|
if "seed" in body_set and req.seed is not None:
|
|
kwargs["seed"] = req.seed
|
|
if ("num_inference_steps" in body_set and req.num_inference_steps is not None):
|
|
kwargs["num_inference_steps"] = req.num_inference_steps
|
|
if "guidance_scale" in body_set and req.guidance_scale is not None:
|
|
kwargs["guidance_scale"] = req.guidance_scale
|
|
if "guidance_scale_2" in body_set and req.guidance_scale_2 is not None:
|
|
kwargs["guidance_scale_2"] = req.guidance_scale_2
|
|
if "negative_prompt" in body_set and req.negative_prompt is not None:
|
|
kwargs["negative_prompt"] = req.negative_prompt
|
|
if "enable_teacache" in body_set and req.enable_teacache:
|
|
kwargs["enable_teacache"] = True
|
|
if "true_cfg_scale" in body_set and req.true_cfg_scale is not None:
|
|
kwargs["true_cfg_scale"] = req.true_cfg_scale
|
|
|
|
if "input_reference" in body_set and req.input_reference is not None:
|
|
kwargs["image_path"] = req.input_reference
|
|
|
|
kwargs.setdefault("fps", 24)
|
|
|
|
default_output_path = kwargs.pop("output_path", None)
|
|
body_output_dir = req.output_path if "output_path" in body_set else None
|
|
output_dir = body_output_dir or default_output_path or os.path.join(get_output_dir(), "videos")
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
kwargs["output_path"] = os.path.join(output_dir, f"{request_id}.mp4")
|
|
kwargs["save_video"] = True
|
|
|
|
return kwargs
|
|
|
|
|
|
def _make_video_job(
|
|
request_id: str,
|
|
req: VideoGenerationsRequest,
|
|
kwargs: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Build the initial job dict stored in VIDEO_STORE."""
|
|
w = kwargs.get("width", 0)
|
|
h = kwargs.get("height", 0)
|
|
size_str = f"{w}x{h}" if w and h else ""
|
|
num_frames = kwargs.get("num_frames", 0)
|
|
fps = kwargs.get("fps", 24)
|
|
seconds = int(round(num_frames / fps)) if fps else 0
|
|
return {
|
|
"id": request_id,
|
|
"object": "video",
|
|
"model": req.model or get_server_args().model_path,
|
|
"status": "queued",
|
|
"progress": 0,
|
|
"created_at": int(time.time()),
|
|
"size": size_str,
|
|
"seconds": str(seconds),
|
|
"quality": "standard",
|
|
"file_path": kwargs.get("output_path"),
|
|
}
|
|
|
|
|
|
async def _run_generation(request_id: str, kwargs: dict[str, Any]) -> None:
|
|
"""
|
|
Run video generation in a background thread (VideoGenerator.generate_video
|
|
is synchronous) and update the store on completion or failure.
|
|
"""
|
|
generator = get_generator()
|
|
loop = asyncio.get_running_loop()
|
|
|
|
try:
|
|
start = time.perf_counter()
|
|
|
|
result = await loop.run_in_executor(
|
|
None,
|
|
lambda: generator.generate_video(**kwargs),
|
|
)
|
|
|
|
elapsed = time.perf_counter() - start
|
|
update: dict[str, Any] = {
|
|
"status": "completed",
|
|
"progress": 100,
|
|
"completed_at": int(time.time()),
|
|
"inference_time_s": elapsed,
|
|
}
|
|
|
|
if isinstance(result, dict):
|
|
gen_time = result.get("generation_time")
|
|
if gen_time is not None:
|
|
update["inference_time_s"] = gen_time
|
|
peak_mem = result.get("peak_memory_mb")
|
|
if peak_mem is not None:
|
|
update["peak_memory_mb"] = peak_mem
|
|
|
|
await VIDEO_STORE.update_fields(request_id, update)
|
|
logger.info("Video %s completed in %.2fs", request_id, elapsed)
|
|
|
|
except Exception as e:
|
|
logger.error("Video generation failed for %s: %s", request_id, e)
|
|
await VIDEO_STORE.update_fields(
|
|
request_id,
|
|
{
|
|
"status": "failed",
|
|
"error": {
|
|
"message": str(e)
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
# Endpoints
|
|
|
|
|
|
@router.post("", response_model=VideoResponse)
|
|
async def create_video(
|
|
request: Request,
|
|
# multipart/form-data fields
|
|
prompt: str | None = Form(None),
|
|
input_reference: UploadFile | None = File(None), # noqa: B008
|
|
reference_url: str | None = Form(None),
|
|
model: str | None = Form(None),
|
|
seconds: int | None = Form(None),
|
|
size: str | None = Form(None),
|
|
fps: int | None = Form(None),
|
|
num_frames: int | None = Form(None),
|
|
seed: int | None = Form(1024),
|
|
negative_prompt: str | None = Form(None),
|
|
guidance_scale: float | None = Form(None),
|
|
num_inference_steps: int | None = Form(None),
|
|
enable_teacache: bool | None = Form(False),
|
|
extra_body: str | None = Form(None),
|
|
):
|
|
content_type = request.headers.get("content-type", "").lower()
|
|
request_id = generate_request_id()
|
|
|
|
if "multipart/form-data" in content_type:
|
|
if not prompt:
|
|
raise HTTPException(status_code=400, detail="prompt is required")
|
|
|
|
input_path = None
|
|
image_list = merge_image_input_list(input_reference, reference_url)
|
|
if image_list:
|
|
image = image_list[0]
|
|
uploads_dir = os.path.join(get_output_dir(), "uploads")
|
|
os.makedirs(uploads_dir, exist_ok=True)
|
|
filename = getattr(image, "filename", "url_image")
|
|
input_path = os.path.join(uploads_dir, f"{request_id}_{filename}")
|
|
try:
|
|
input_path = await save_image_to_path(image, input_path)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Failed to process image: {e}",
|
|
) from None
|
|
|
|
extra: dict[str, Any] = {}
|
|
if extra_body:
|
|
try:
|
|
extra = json.loads(extra_body)
|
|
except Exception:
|
|
extra = {}
|
|
|
|
req = VideoGenerationsRequest(
|
|
prompt=prompt,
|
|
input_reference=input_path,
|
|
model=model,
|
|
seconds=seconds if seconds is not None else 4,
|
|
size=size,
|
|
fps=fps if fps is not None else extra.get("fps"),
|
|
num_frames=(num_frames if num_frames is not None else extra.get("num_frames")),
|
|
seed=seed,
|
|
negative_prompt=negative_prompt,
|
|
num_inference_steps=num_inference_steps,
|
|
enable_teacache=enable_teacache,
|
|
**({
|
|
"guidance_scale": guidance_scale
|
|
} if guidance_scale is not None else {}),
|
|
)
|
|
else:
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
|
|
payload: dict[str, Any] = dict(body or {})
|
|
for key in ("extra_body", "extra_json"):
|
|
extra = payload.pop(key, None)
|
|
if isinstance(extra, dict):
|
|
payload.update(extra)
|
|
|
|
if payload.get("reference_url"):
|
|
image_list = merge_image_input_list(payload.get("reference_url"))
|
|
if image_list:
|
|
image = image_list[0]
|
|
uploads_dir = os.path.join(get_output_dir(), "uploads")
|
|
os.makedirs(uploads_dir, exist_ok=True)
|
|
input_path = os.path.join(uploads_dir, f"{request_id}_url_image")
|
|
try:
|
|
input_path = await save_image_to_path(image, input_path)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Failed to process image: {e}",
|
|
) from None
|
|
payload["input_reference"] = input_path
|
|
|
|
try:
|
|
req = VideoGenerationsRequest(**payload)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid request body: {e}",
|
|
) from None
|
|
|
|
logger.info("Video generation request %s: prompt=%s", request_id, req.prompt[:100])
|
|
|
|
# default_request was validated at server startup (run_server) and is
|
|
# read-only on the request hot path — _build_generation_kwargs and
|
|
# explicit_request_updates only read, so no per-request deepcopy needed.
|
|
default_request = get_default_request()
|
|
|
|
gen_kwargs = _build_generation_kwargs(request_id, req, default_request=default_request)
|
|
job = _make_video_job(request_id, req, gen_kwargs)
|
|
await VIDEO_STORE.upsert(request_id, job)
|
|
|
|
asyncio.create_task(_run_generation(request_id, gen_kwargs))
|
|
|
|
return VideoResponse(**job)
|
|
|
|
|
|
@router.get("", response_model=VideoListResponse)
|
|
async def list_videos(
|
|
after: str | None = Query(None),
|
|
limit: int | None = Query(None, ge=1, le=100),
|
|
order: str | None = Query("desc"),
|
|
):
|
|
order = (order or "desc").lower()
|
|
if order not in ("asc", "desc"):
|
|
order = "desc"
|
|
jobs = await VIDEO_STORE.list_values()
|
|
jobs.sort(key=lambda j: j.get("created_at", 0), reverse=(order != "asc"))
|
|
|
|
if after is not None:
|
|
try:
|
|
idx = next(i for i, j in enumerate(jobs) if j["id"] == after)
|
|
jobs = jobs[idx + 1:]
|
|
except StopIteration:
|
|
jobs = []
|
|
|
|
if limit is not None:
|
|
jobs = jobs[:limit]
|
|
return VideoListResponse(data=[VideoResponse(**j) for j in jobs])
|
|
|
|
|
|
@router.get("/{video_id}", response_model=VideoResponse)
|
|
async def retrieve_video(video_id: str = Path(...)):
|
|
job = await VIDEO_STORE.get(video_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Video not found")
|
|
return VideoResponse(**job)
|
|
|
|
|
|
@router.delete("/{video_id}", response_model=VideoResponse)
|
|
async def delete_video(video_id: str = Path(...)):
|
|
job = await VIDEO_STORE.pop(video_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Video not found")
|
|
job["status"] = "deleted"
|
|
return VideoResponse(**job)
|
|
|
|
|
|
@router.get("/{video_id}/content")
|
|
async def download_video_content(video_id: str = Path(...), variant: str | None = Query(None)):
|
|
job = await VIDEO_STORE.get(video_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Video not found")
|
|
|
|
file_path = job.get("file_path")
|
|
if not file_path or not os.path.exists(file_path):
|
|
if job.get("status") == "failed":
|
|
raise HTTPException(status_code=500, detail="Video generation failed")
|
|
raise HTTPException(status_code=404, detail="Video still being generated")
|
|
|
|
return FileResponse(
|
|
path=file_path,
|
|
media_type="video/mp4",
|
|
filename=os.path.basename(file_path),
|
|
)
|