# SPDX-License-Identifier: Apache-2.0 """OpenAI/vLLM-Omni-compatible video generation routes.""" from __future__ import annotations import asyncio import json import os import time from contextlib import suppress from typing import Any from fastapi import APIRouter, HTTPException, Path, Query, Request from fastapi.responses import FileResponse, JSONResponse from pydantic import ValidationError from starlette.background import BackgroundTask from starlette.datastructures import UploadFile from fastvideo.api.compat import explicit_request_updates, request_to_sampling_param from fastvideo.api.schema import GenerationRequest from fastvideo.entrypoints.openai.protocol import ( VideoDeleteResponse, VideoGenerationRequest, VideoGenerationStatus, VideoListResponse, VideoResponse, generate_request_id, ) from fastvideo.entrypoints.openai.request_adapter import ( build_generation_request, prepare_reference_media, validate_model_and_lora, ) from fastvideo.entrypoints.openai.state import ( get_default_request, get_output_dir, get_served_model_name, get_server_args, get_serving_engine, ) from fastvideo.entrypoints.openai.stores import VIDEO_STORE from fastvideo.entrypoints.openai.utils import parse_size, save_image_to_path from fastvideo.logger import init_logger logger = init_logger(__name__) router = APIRouter(prefix="/v1/videos", tags=["videos"]) _VIDEO_JOB_TASKS: dict[str, asyncio.Task[None]] = {} _DELETED_VIDEO_IDS: set[str] = set() _JSON_FORM_FIELDS = { "image_reference", "video_reference", "audio_reference", "video_params", "lora", "extra_params", } _VIDEO_EXTENSIONS = {".avi", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm"} def _build_generation_kwargs( request_id: str, req: VideoGenerationRequest, default_request: GenerationRequest | None = None, ) -> dict[str, Any]: """Backward-compatible flat projection used by helper-level callers. Runtime serving uses :func:`build_generation_request` and the typed ``VideoGenerator.generate`` API. Keeping this helper avoids breaking code that imported the original FastVideo adapter directly. """ kwargs: dict[str, Any] = {} if default_request is not None: kwargs.update(explicit_request_updates(default_request)) body_set = req.model_fields_set nested_set = req.video_params.model_fields_set if req.video_params is not None else set() kwargs["prompt"] = req.prompt if "size" in body_set and req.size: width, height = parse_size(req.size) if width is not None and height is not None: kwargs["width"], kwargs["height"] = width, height else: if "width" in body_set and req.width is not None: kwargs["width"] = req.width elif "video_params" in body_set and "width" in nested_set and req.video_params.width is not None: kwargs["width"] = req.video_params.width if "height" in body_set and req.height is not None: kwargs["height"] = req.height elif "video_params" in body_set and "height" in nested_set and req.video_params.height is not None: kwargs["height"] = req.video_params.height if "fps" in body_set and req.fps is not None: kwargs["fps"] = req.fps elif "video_params" in body_set and "fps" in nested_set and req.video_params.fps is not None: kwargs["fps"] = req.video_params.fps kwargs.setdefault("fps", 24) if "num_frames" in body_set and req.num_frames is not None: kwargs["num_frames"] = req.num_frames elif "video_params" in body_set and "num_frames" in nested_set and req.video_params.num_frames is not None: kwargs["num_frames"] = req.video_params.num_frames elif "seconds" in body_set and req.seconds is not None: kwargs["num_frames"] = int(req.seconds) * int(kwargs["fps"]) for name in ( "seed", "num_inference_steps", "guidance_scale", "guidance_scale_2", "true_cfg_scale", "negative_prompt", "enable_teacache", "max_sequence_length", "boundary_ratio", ): if name in body_set and getattr(req, name) is not None: kwargs[name] = getattr(req, name) if "n" in body_set or "num_outputs_per_prompt" in body_set: kwargs["num_videos_per_prompt"] = req.resolved_num_outputs if "input_reference" in body_set and req.input_reference is not None: kwargs["image_path"] = req.input_reference kwargs.pop("output_path", None) output_dir = os.path.join(os.path.abspath(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 _result_value(result: Any, name: str, default: Any = None) -> Any: if isinstance(result, dict): return result.get(name, default) return getattr(result, name, default) def _remove_artifact(file_path: str | None) -> None: if not file_path or not os.path.isfile(file_path): return try: os.unlink(file_path) except OSError: logger.warning("Failed to delete video artifact %s", file_path, exc_info=True) def _stage_durations(result: Any) -> dict[str, float]: logging_info = _result_value(result, "logging_info") stages = getattr(logging_info, "stages", None) if not isinstance(stages, dict): return {} durations: dict[str, float] = {} for stage_name, metrics in stages.items(): if isinstance(metrics, dict) and metrics.get("execution_time") is not None: durations[str(stage_name)] = float(metrics["execution_time"]) return durations def _make_video_job( request_id: str, req: VideoGenerationRequest, generation_request: GenerationRequest, ) -> dict[str, Any]: sampling = request_to_sampling_param(generation_request, model_path=get_server_args().model_path) size = f"{sampling.width}x{sampling.height}" if sampling.width and sampling.height else None seconds = int(round(sampling.num_frames / sampling.fps)) if sampling.fps else int(req.seconds or 4) return { "id": request_id, "object": "video", "model": req.model or get_served_model_name(), "prompt": req.prompt, "status": VideoGenerationStatus.QUEUED, "progress": 0, "created_at": int(time.time()), "size": size, "seconds": str(max(1, seconds)), "quality": req.quality or "default", # ``file_path`` is a FastVideo compatibility extension. vLLM-Omni's # public job shape uses ``file_name`` after completion. "file_path": None, "_sequence": time.monotonic_ns(), } async def _run_generation( request_id: str, generation_request: GenerationRequest, ) -> None: started = 0.0 video_path = generation_request.output.output_path async def mark_started() -> None: nonlocal started started = time.perf_counter() await VIDEO_STORE.update_fields( request_id, { "status": VideoGenerationStatus.IN_PROGRESS, "progress": 0 }, ) try: result = await get_serving_engine().generate(generation_request, on_start=mark_started) if isinstance(result, list): if not result: raise RuntimeError("FastVideo returned no generation results") result = result[0] elapsed = time.perf_counter() - started video_path = _result_value(result, "video_path") or generation_request.output.output_path generation_time = _result_value(result, "generation_time", elapsed) await VIDEO_STORE.update_fields( request_id, { "status": VideoGenerationStatus.COMPLETED, "progress": 100, "completed_at": int(time.time()), "file_path": video_path, "file_name": os.path.basename(video_path) if video_path else None, "inference_time_s": float(generation_time or elapsed), "peak_memory_mb": _result_value(result, "peak_memory_mb"), "stage_durations": _stage_durations(result), }, ) logger.info("Video %s completed in %.2fs", request_id, elapsed) except asyncio.CancelledError: logger.info("Video %s was cancelled", request_id) raise except Exception as error: logger.exception("Video generation failed for %s", request_id) await VIDEO_STORE.update_fields( request_id, { "status": VideoGenerationStatus.FAILED, "error": { "code": 500, "message": str(error) }, "inference_time_s": time.perf_counter() - started if started else 0.0, }, ) finally: if request_id in _DELETED_VIDEO_IDS: _DELETED_VIDEO_IDS.discard(request_id) if video_path and os.path.isfile(video_path): try: os.unlink(video_path) except OSError: logger.warning("Failed to clean up deleted video artifact %s", video_path, exc_info=True) def _track_video_job(request_id: str, task: asyncio.Task[None]) -> None: _VIDEO_JOB_TASKS[request_id] = task def discard(completed: asyncio.Task[None]) -> None: if _VIDEO_JOB_TASKS.get(request_id) is completed: _VIDEO_JOB_TASKS.pop(request_id, None) task.add_done_callback(discard) async def shutdown_video_jobs() -> None: """Cancel all transport tasks before the serving engine shuts down.""" tasks = list(_VIDEO_JOB_TASKS.values()) _VIDEO_JOB_TASKS.clear() for task in tasks: task.cancel() for task in tasks: with suppress(asyncio.CancelledError): await task def _parse_json_form_value(name: str, value: Any) -> Any: if value is None or not isinstance(value, str) or name not in _JSON_FORM_FIELDS: return value try: return json.loads(value) except json.JSONDecodeError as error: raise HTTPException(status_code=400, detail=f"{name} is not valid JSON") from error async def _parse_video_request(raw_request: Request) -> VideoGenerationRequest: content_type = raw_request.headers.get("content-type", "").lower() if "multipart/form-data" in content_type or "application/x-www-form-urlencoded" in content_type: form = await raw_request.form() payload: dict[str, Any] = {} for name, value in form.multi_items(): if name == "input_reference" and isinstance(value, UploadFile): uploads_dir = os.path.join(get_output_dir(), "uploads") filename = os.path.basename(value.filename or "reference") target = os.path.join(uploads_dir, f"{generate_request_id()}_{filename}") saved_path = await save_image_to_path(value, target) upload_ext = os.path.splitext(filename)[1].lower() if (value.content_type or "").lower().startswith("video/") or upload_ext in _VIDEO_EXTENSIONS: payload["video_reference"] = {"video_url": saved_path} else: payload["input_reference"] = saved_path continue parsed = _parse_json_form_value(name, value) if name in payload: current = payload[name] payload[name] = current + [parsed] if isinstance(current, list) else [current, parsed] else: payload[name] = parsed else: try: body = await raw_request.json() except Exception as error: raise HTTPException(status_code=400, detail="Request body must be valid JSON") from error if not isinstance(body, dict): raise HTTPException(status_code=400, detail="Request body must be a JSON object") payload = dict(body) for name in ("extra_body", "extra_json"): extra = payload.pop(name, None) if isinstance(extra, str): try: extra = json.loads(extra) except json.JSONDecodeError as error: raise HTTPException(status_code=400, detail=f"{name} is not valid JSON") from error if extra is not None and not isinstance(extra, dict): raise HTTPException(status_code=400, detail=f"{name} must be a JSON object") if extra: payload.update(extra) try: return VideoGenerationRequest(**payload) except ValidationError as error: raise HTTPException(status_code=400, detail=f"Invalid request body: {error}") from error async def _adapt_request(request_id: str, request: VideoGenerationRequest) -> GenerationRequest: try: validate_model_and_lora(request, get_server_args(), get_served_model_name()) await prepare_reference_media(request_id, request, get_output_dir()) return await asyncio.to_thread( build_generation_request, request_id, request, get_server_args(), served_model_name=get_served_model_name(), output_dir=get_output_dir(), default_request=get_default_request(), ) except Exception as error: raise HTTPException(status_code=400, detail=str(error)) from error @router.post("", response_model=VideoResponse) @router.post("/generations", response_model=VideoResponse, include_in_schema=False) async def create_video(raw_request: Request) -> VideoResponse: """Create an asynchronous video generation job.""" request = await _parse_video_request(raw_request) request_id = f"video_gen_{generate_request_id()}" generation_request = await _adapt_request(request_id, request) job = _make_video_job(request_id, request, generation_request) await VIDEO_STORE.upsert(request_id, job) task = asyncio.create_task(_run_generation(request_id, generation_request), name=f"video-job-{request_id}") _track_video_job(request_id, task) return VideoResponse(**job) @router.post("/sync") async def create_video_sync(raw_request: Request) -> FileResponse: """Generate synchronously and return raw MP4 bytes with vLLM headers.""" request = await _parse_video_request(raw_request) request_id = f"video_sync-{generate_request_id()}" generation_request = await _adapt_request(request_id, request) started = time.perf_counter() try: result = await get_serving_engine().generate(generation_request) except Exception as error: logger.exception("Sync video generation failed for %s", request_id) raise HTTPException(status_code=500, detail=f"Video generation failed: {error}") from error if isinstance(result, list): if not result: raise HTTPException(status_code=500, detail="FastVideo returned no generation results") result = result[0] elapsed = time.perf_counter() - started video_path = _result_value(result, "video_path") or generation_request.output.output_path if not video_path or not os.path.exists(video_path): raise HTTPException(status_code=500, detail="FastVideo did not produce an MP4 file") return FileResponse( video_path, media_type="video/mp4", filename=os.path.basename(video_path), headers={ "X-Request-Id": request_id, "X-Model": get_served_model_name(), "X-Inference-Time-S": f"{elapsed:.3f}", "X-Stage-Durations": json.dumps(_stage_durations(result), separators=(",", ":")), "X-Peak-Memory-MB": f"{float(_result_value(result, 'peak_memory_mb', 0.0) or 0.0):.3f}", }, background=BackgroundTask(_remove_artifact, video_path), ) @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 = Query("desc"), ) -> VideoListResponse: order = order.lower() if order not in {"asc", "desc"}: raise HTTPException(status_code=400, detail="order must be 'asc' or 'desc'") jobs = await VIDEO_STORE.list_values() jobs.sort(key=lambda job: (job.get("created_at", 0), job.get("_sequence", 0)), reverse=order == "desc") if after is not None: index = next((i for i, job in enumerate(jobs) if job.get("id") == after), None) jobs = [] if index is None else jobs[index + 1:] has_more = limit is not None and len(jobs) > limit if limit is not None: jobs = jobs[:limit] responses = [VideoResponse(**job) for job in jobs] return VideoListResponse( data=responses, first_id=responses[0].id if responses else None, last_id=responses[-1].id if responses else None, has_more=has_more, ) @router.get("/{video_id}", response_model=None) async def retrieve_video(video_id: str = Path(...)) -> VideoResponse | JSONResponse: job = await VIDEO_STORE.get(video_id) if job is None: raise HTTPException(status_code=404, detail="Video not found") response = VideoResponse(**job) if response.status is VideoGenerationStatus.FAILED: return JSONResponse(status_code=500, content=response.model_dump(mode="json")) return response @router.delete("/{video_id}", response_model=VideoDeleteResponse) async def delete_video(video_id: str = Path(...)) -> VideoDeleteResponse: job = await VIDEO_STORE.get(video_id) if job is None: raise HTTPException(status_code=404, detail="Video not found") task = _VIDEO_JOB_TASKS.get(video_id) status = VideoGenerationStatus(job.get("status", VideoGenerationStatus.QUEUED)) if task is not None and not task.done(): # The current synchronous generator cannot abort a CUDA call once it # starts. Remove the API resource immediately and let the tracked task # clean up its artifact on exit while the serving lock stays held. if status is VideoGenerationStatus.QUEUED: task.cancel() else: _DELETED_VIDEO_IDS.add(video_id) task.cancel() popped = await VIDEO_STORE.pop(video_id) file_path = None if popped is None else popped.get("file_path") if status in {VideoGenerationStatus.COMPLETED, VideoGenerationStatus.FAILED}: _remove_artifact(file_path) return VideoDeleteResponse(id=video_id, deleted=True) @router.get("/{video_id}/content") async def download_video_content(video_id: str = Path(...), variant: str | None = Query(None)) -> FileResponse: del variant job = await VIDEO_STORE.get(video_id) if job is None: raise HTTPException(status_code=404, detail="Video not found") status = VideoGenerationStatus(job.get("status", VideoGenerationStatus.QUEUED)) if status is VideoGenerationStatus.FAILED: raise HTTPException(status_code=422, detail="Video generation failed. Check job status for error details.") file_path = job.get("file_path") if status is not VideoGenerationStatus.COMPLETED or not file_path: raise HTTPException(status_code=404, detail="Generation is still in-progress") if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="Generated video file not found on disk") return FileResponse(path=file_path, media_type="video/mp4", filename=os.path.basename(file_path)) __all__ = [ "_build_generation_kwargs", "create_video", "create_video_sync", "delete_video", "download_video_content", "list_videos", "retrieve_video", "router", "shutdown_video_jobs", ]