1081 lines
45 KiB
Python
1081 lines
45 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import math
|
|
import time
|
|
from collections.abc import Awaitable, Callable, Mapping
|
|
from dataclasses import dataclass
|
|
from functools import cached_property
|
|
from http import HTTPStatus
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
import pybase64 as base64
|
|
from fastapi import HTTPException
|
|
from PIL import Image
|
|
from vllm.engine.protocol import EngineClient
|
|
from vllm.logger import init_logger
|
|
|
|
from vllm_omni.diffusion.data import is_diffusion_request_started_output
|
|
from vllm_omni.diffusion.model_metadata import DiffusionModelMetadata, get_diffusion_model_metadata
|
|
from vllm_omni.diffusion.utils.media_utils import count_mp4_frames, normalize_preencode_batch_frames
|
|
from vllm_omni.entrypoints.async_omni import ABORT_TIMEOUT_S, AsyncOmni
|
|
from vllm_omni.entrypoints.openai.protocol.videos import (
|
|
VideoAction,
|
|
VideoData,
|
|
VideoGenerationRequest,
|
|
VideoGenerationResponse,
|
|
)
|
|
from vllm_omni.entrypoints.openai.stage_params import (
|
|
build_stage_sampling_params_list,
|
|
get_default_sampling_params_list,
|
|
)
|
|
from vllm_omni.entrypoints.openai.utils import is_video_generation_pipeline, parse_lora_request
|
|
from vllm_omni.entrypoints.openai.video_api_utils import (
|
|
_encode_video_bytes,
|
|
_PlanarFrameConverter,
|
|
encode_video_base64,
|
|
)
|
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt
|
|
from vllm_omni.metrics import count_video_frames
|
|
from vllm_omni.model_extras import get_video_generation_defaults, should_preserve_reference_image_size
|
|
from vllm_omni.model_extras.video_generation import VideoGenerationDefaults
|
|
from vllm_omni.outputs.output_metadata import (
|
|
DiffusionMetadataMapping,
|
|
DiffusionMultimodalOutput,
|
|
DiffusionPayloadValue,
|
|
)
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
_VIDEO_RESPONSE_FRAME_CONVERSION_WORKERS = 8
|
|
|
|
|
|
def _config_value(config: Any, key: str, default: Any = None) -> Any:
|
|
if isinstance(config, Mapping):
|
|
return config.get(key, default)
|
|
return getattr(config, key, default)
|
|
|
|
|
|
def _stage_diffusion_model_class_name(stage_config: Any) -> str | None:
|
|
"""Resolve a stage's diffusion class across typed and legacy shapes."""
|
|
diffusion_config = _config_value(stage_config, "diffusion_config")
|
|
model_class_name = _config_value(diffusion_config, "model_class_name")
|
|
if model_class_name:
|
|
return str(model_class_name)
|
|
|
|
model_config = _config_value(stage_config, "model_config")
|
|
model_arch = _config_value(model_config, "model_arch") or _config_value(stage_config, "model_arch")
|
|
if model_arch:
|
|
return str(model_arch)
|
|
|
|
engine_args = _config_value(stage_config, "engine_args", {})
|
|
model_class_name = _config_value(engine_args, "model_class_name")
|
|
return str(model_class_name) if model_class_name else None
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
|
|
|
|
|
@dataclass
|
|
class ReferenceImage:
|
|
"""Reference class for tracking additional metadata if needed"""
|
|
|
|
data: Image.Image | list[Image.Image]
|
|
|
|
|
|
@dataclass
|
|
class ReferenceVideo:
|
|
"""Reference video frames for video-conditioned generation."""
|
|
|
|
data: list[Image.Image] | list[str]
|
|
cleanup_paths: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass
|
|
class ReferenceAudio:
|
|
"""Reference audio file path for speech-to-video generation."""
|
|
|
|
path: str | list[str]
|
|
cleanup_paths: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass
|
|
class LatentEditInput:
|
|
"""Request-scoped source media and masks for latent-mask editing."""
|
|
|
|
source_video: str | None = None
|
|
source_audio: str | None = None
|
|
video_noise_mask: Any | None = None
|
|
audio_noise_mask: Any | None = None
|
|
cleanup_paths: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass
|
|
class VideoGenerationArtifacts:
|
|
"""Normalized outputs and profiler metadata extracted from one request."""
|
|
|
|
videos: list[DiffusionPayloadValue]
|
|
audios: list[DiffusionPayloadValue | None]
|
|
actions: list[VideoAction | None]
|
|
audio_sample_rate: int
|
|
output_fps: float
|
|
stage_durations: dict[str, float]
|
|
peak_memory_mb: float
|
|
metrics: dict[str, object] | None = None
|
|
|
|
|
|
def _video_metadata_from_artifacts(artifacts: VideoGenerationArtifacts) -> dict[str, object]:
|
|
metadata: dict[str, object] = {}
|
|
if artifacts.output_fps > 0:
|
|
metadata["fps"] = artifacts.output_fps
|
|
|
|
if not artifacts.videos:
|
|
return metadata
|
|
|
|
video = artifacts.videos[0]
|
|
# Pre-encoded outputs arrive as MP4 bytes, which carry no tensor shape.
|
|
if isinstance(video, (bytes, bytearray, memoryview)):
|
|
num_frames = count_mp4_frames(bytes(video))
|
|
else:
|
|
num_frames = count_video_frames(video)
|
|
if num_frames is not None and num_frames > 0:
|
|
metadata["num_frames"] = num_frames
|
|
if artifacts.output_fps > 0:
|
|
metadata["duration_s"] = num_frames / artifacts.output_fps
|
|
|
|
if artifacts.metrics:
|
|
metadata["metrics"] = artifacts.metrics
|
|
|
|
return metadata
|
|
|
|
|
|
class OmniOpenAIServingVideo:
|
|
"""OpenAI-style video generation handler for omni diffusion models."""
|
|
|
|
def __init__(
|
|
self,
|
|
engine_client: EngineClient,
|
|
model_name: str | None = None,
|
|
stage_configs: list[Any] | None = None,
|
|
) -> None:
|
|
self._engine_client = engine_client
|
|
self._model_name = model_name
|
|
self._stage_configs = stage_configs
|
|
self._video_frame_converter = _PlanarFrameConverter(max_workers=_VIDEO_RESPONSE_FRAME_CONVERSION_WORKERS)
|
|
logger.info(
|
|
"Video response frame conversion pool configured: workers=%d",
|
|
self._video_frame_converter.max_workers,
|
|
)
|
|
|
|
def _resolve_diffusion_od_config(self) -> OmniDiffusionConfig | SimpleNamespace | None:
|
|
get_od_config = getattr(self._engine_client, "get_diffusion_od_config", None)
|
|
if callable(get_od_config):
|
|
return get_od_config()
|
|
return getattr(self._engine_client, "od_config", None)
|
|
|
|
def _resolve_diffusion_metadata(
|
|
self,
|
|
) -> tuple[OmniDiffusionConfig | SimpleNamespace | None, tuple[DiffusionModelMetadata, ...]]:
|
|
"""Resolve capability metadata from the runtime and diffusion stages."""
|
|
od_config = self._resolve_diffusion_od_config()
|
|
model_archs = [None if od_config is None else getattr(od_config, "model_class_name", None)]
|
|
model_archs.extend(_stage_diffusion_model_class_name(stage) for stage in self.stage_configs or ())
|
|
return od_config, tuple(get_diffusion_model_metadata(model_arch) for model_arch in model_archs)
|
|
|
|
def _resolve_video_generation_defaults(
|
|
self,
|
|
request: VideoGenerationRequest,
|
|
) -> VideoGenerationDefaults | None:
|
|
"""Resolve defaults owned by the active diffusion pipeline."""
|
|
od_config = self._resolve_diffusion_od_config()
|
|
model_class_name = None if od_config is None else getattr(od_config, "model_class_name", None)
|
|
try:
|
|
return get_video_generation_defaults(model_class_name, request.extra_params)
|
|
except (TypeError, ValueError) as exc:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST.value,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
@property
|
|
def model_name(self) -> str | None:
|
|
return self._model_name
|
|
|
|
@property
|
|
def stage_configs(self) -> list[Any] | None:
|
|
return self._stage_configs
|
|
|
|
def set_stage_configs_if_missing(self, stage_configs: list[Any] | None) -> None:
|
|
if self._stage_configs is None and stage_configs is not None:
|
|
self._stage_configs = stage_configs
|
|
|
|
@cached_property
|
|
def preserves_reference_image_size(self) -> bool:
|
|
"""Return whether the active pipeline owns reference-image resizing."""
|
|
od_config = self._resolve_diffusion_od_config()
|
|
model_class_name = None if od_config is None else getattr(od_config, "model_class_name", None)
|
|
model = getattr(od_config, "model", None) if od_config is not None else None
|
|
model = model or self.model_name
|
|
revision = getattr(od_config, "revision", None) if od_config is not None else None
|
|
return should_preserve_reference_image_size(
|
|
model_class_name,
|
|
model=None if model is None else str(model),
|
|
revision=revision,
|
|
)
|
|
|
|
@property
|
|
def supports_mixed_reference_inputs(self) -> bool:
|
|
"""Return whether the configured diffusion model accepts mixed refs."""
|
|
od_config, metadata = self._resolve_diffusion_metadata()
|
|
if od_config is None:
|
|
return False
|
|
|
|
capability = getattr(od_config, "supports_mixed_reference_inputs", None)
|
|
return capability is True or any(item.supports_mixed_reference_inputs for item in metadata)
|
|
|
|
@property
|
|
def supports_latent_mask_editing(self) -> bool:
|
|
"""Return whether the configured diffusion model accepts latent edits."""
|
|
_, metadata = self._resolve_diffusion_metadata()
|
|
return any(item.supports_latent_mask_editing for item in metadata)
|
|
|
|
@property
|
|
def supported_control_upload_types(self) -> frozenset[str]:
|
|
"""Return multipart control types accepted by the active pipeline.
|
|
|
|
Unknown pipelines deliberately return an empty set. This keeps the
|
|
generic video API isolated from model-specific controls unless a model
|
|
explicitly opts into the ``control_path`` contract in metadata.
|
|
"""
|
|
_, metadata = self._resolve_diffusion_metadata()
|
|
|
|
supported: set[str] = set()
|
|
for item in metadata:
|
|
supported.update(item.supported_control_upload_types)
|
|
return frozenset(supported)
|
|
|
|
@classmethod
|
|
def for_diffusion(
|
|
cls,
|
|
diffusion_engine: EngineClient,
|
|
model_name: str,
|
|
stage_configs: list[Any] | None = None,
|
|
) -> OmniOpenAIServingVideo:
|
|
return cls(
|
|
diffusion_engine,
|
|
model_name=model_name,
|
|
stage_configs=stage_configs,
|
|
)
|
|
|
|
def shutdown(self) -> None:
|
|
self._video_frame_converter.shutdown()
|
|
|
|
async def abort_request(self, request_id: str) -> None:
|
|
await self._engine_client.abort(request_id, timeout=ABORT_TIMEOUT_S)
|
|
|
|
async def _run_and_extract(
|
|
self,
|
|
request: VideoGenerationRequest,
|
|
reference_id: str,
|
|
*,
|
|
reference_image: ReferenceImage | None = None,
|
|
reference_video: ReferenceVideo | None = None,
|
|
reference_audio: ReferenceAudio | None = None,
|
|
on_started: Callable[[], Awaitable[None]] | None = None,
|
|
latent_edit_input: LatentEditInput | None = None,
|
|
) -> VideoGenerationArtifacts:
|
|
"""Run the generation pipeline and extract video/audio/profiler outputs."""
|
|
prompt: OmniTextPrompt = OmniTextPrompt(prompt=request.prompt, modalities=["video"])
|
|
if request.negative_prompt is not None:
|
|
prompt["negative_prompt"] = request.negative_prompt
|
|
|
|
gen_params = self._resolve_default_sampling_params()
|
|
|
|
input_image = None if reference_image is None else reference_image.data
|
|
input_video = None if reference_video is None else reference_video.data
|
|
if input_image is not None and input_video is not None and not self.supports_mixed_reference_inputs:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST.value,
|
|
detail="This diffusion model does not support mixed image and video references.",
|
|
)
|
|
provided_fields = request.model_fields_set
|
|
fps_provided = self._request_fps_provided(request)
|
|
num_frames_provided = self._request_num_frames_provided(request)
|
|
video_defaults = self._resolve_video_generation_defaults(request)
|
|
if video_defaults is None:
|
|
vp = request.resolve_video_params()
|
|
else:
|
|
vp = request.resolve_video_params(
|
|
default_fps=video_defaults.fps,
|
|
default_num_frames=video_defaults.num_frames,
|
|
)
|
|
vp.width = vp.width or video_defaults.width
|
|
vp.height = vp.height or video_defaults.height
|
|
if num_frames_provided and vp.num_frames != video_defaults.num_frames:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST.value,
|
|
detail=(f"This diffusion model requires {video_defaults.num_frames} frames; got {vp.num_frames}."),
|
|
)
|
|
if "num_inference_steps" not in provided_fields and gen_params.num_inference_steps is None:
|
|
gen_params.num_inference_steps = video_defaults.num_inference_steps
|
|
|
|
# Some native pipelines have a fixed duration. Validate both the
|
|
# OpenAI top-level field and model-specific aliases before dispatching
|
|
# the request; otherwise ``seconds`` would reach MAGI-2 as a duration
|
|
# override and fail only after the worker has started generation.
|
|
if video_defaults is not None and video_defaults.duration_seconds is not None:
|
|
requested_durations: list[float] = []
|
|
try:
|
|
if request.seconds is not None:
|
|
requested_durations.append(float(request.seconds))
|
|
if request.extra_params is not None:
|
|
for key in ("seconds", "duration"):
|
|
if key in request.extra_params:
|
|
requested_durations.append(float(request.extra_params[key]))
|
|
except (TypeError, ValueError) as exc:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST.value,
|
|
detail="The requested video duration must be a finite number of seconds.",
|
|
) from exc
|
|
expected_duration = video_defaults.duration_seconds
|
|
if any(not math.isfinite(duration) or duration != expected_duration for duration in requested_durations):
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST.value,
|
|
detail=f"This diffusion model supports {expected_duration:g}-second clips only.",
|
|
)
|
|
if (
|
|
input_image is not None
|
|
and vp.width is not None
|
|
and vp.height is not None
|
|
and not self.preserves_reference_image_size
|
|
):
|
|
target_size = (vp.width, vp.height)
|
|
image_items = input_image if isinstance(input_image, list) else [input_image]
|
|
resized_images = [
|
|
image.resize(target_size, Image.Resampling.LANCZOS) if image.size != target_size else image
|
|
for image in image_items
|
|
]
|
|
input_image = resized_images if isinstance(input_image, list) else resized_images[0]
|
|
multi_modal_data: dict[str, Any] = {}
|
|
if input_image is not None:
|
|
multi_modal_data["image"] = input_image
|
|
if input_video is not None:
|
|
multi_modal_data["video"] = input_video
|
|
if reference_audio is not None:
|
|
multi_modal_data["audio"] = reference_audio.path
|
|
if latent_edit_input is not None:
|
|
multi_modal_data.update(
|
|
{
|
|
key: value
|
|
for key, value in {
|
|
"source_video": latent_edit_input.source_video,
|
|
"source_audio": latent_edit_input.source_audio,
|
|
"video_noise_mask": latent_edit_input.video_noise_mask,
|
|
"audio_noise_mask": latent_edit_input.audio_noise_mask,
|
|
}.items()
|
|
if value is not None
|
|
}
|
|
)
|
|
if multi_modal_data:
|
|
prompt["multi_modal_data"] = multi_modal_data
|
|
if vp.width is not None and vp.height is not None:
|
|
gen_params.width = vp.width
|
|
gen_params.height = vp.height
|
|
if vp.num_frames is not None:
|
|
gen_params.num_frames = vp.num_frames
|
|
gen_params.num_outputs_per_prompt = request.num_outputs_per_prompt
|
|
if request.seconds is not None:
|
|
if video_defaults is None or video_defaults.duration_seconds is None:
|
|
gen_params.extra_args.setdefault("duration", float(request.seconds))
|
|
if request.aspect_ratio is not None:
|
|
gen_params.extra_args["aspect_ratio"] = request.aspect_ratio
|
|
if request.short_edge is not None:
|
|
gen_params.extra_args["short_edge"] = request.short_edge
|
|
if request.start_time_seconds is not None:
|
|
gen_params.extra_args["start_time_seconds"] = request.start_time_seconds
|
|
# Model-owned defaults are part of the serving contract. Other models
|
|
# preserve their engine defaults when the user did not provide fps.
|
|
if (fps_provided or video_defaults is not None) and vp.fps is not None:
|
|
gen_params.fps = vp.fps
|
|
gen_params.frame_rate = float(vp.fps)
|
|
if "enable_frame_interpolation" in provided_fields:
|
|
gen_params.enable_frame_interpolation = request.enable_frame_interpolation
|
|
if "frame_interpolation_exp" in provided_fields:
|
|
gen_params.frame_interpolation_exp = request.frame_interpolation_exp
|
|
if "frame_interpolation_scale" in provided_fields:
|
|
gen_params.frame_interpolation_scale = request.frame_interpolation_scale
|
|
if "frame_interpolation_model_path" in provided_fields:
|
|
gen_params.frame_interpolation_model_path = request.frame_interpolation_model_path
|
|
|
|
if "num_inference_steps" in provided_fields and request.num_inference_steps is not None:
|
|
gen_params.num_inference_steps = request.num_inference_steps
|
|
if "quality" in provided_fields:
|
|
gen_params.quality = request.quality
|
|
if "guidance_scale" in provided_fields and request.guidance_scale is not None:
|
|
gen_params.guidance_scale = request.guidance_scale
|
|
if "guidance_scale_2" in provided_fields and request.guidance_scale_2 is not None:
|
|
gen_params.guidance_scale_2 = request.guidance_scale_2
|
|
if "true_cfg_scale" in provided_fields and request.true_cfg_scale is not None:
|
|
gen_params.true_cfg_scale = request.true_cfg_scale
|
|
if "seed" in provided_fields and request.seed is not None:
|
|
gen_params.seed = request.seed
|
|
if "boundary_ratio" in provided_fields and request.boundary_ratio is not None:
|
|
gen_params.boundary_ratio = request.boundary_ratio
|
|
|
|
logger.info(
|
|
"Boundary ratio parse: request=%s gen_params=%s",
|
|
request.boundary_ratio,
|
|
gen_params.boundary_ratio,
|
|
)
|
|
if "flow_shift" in provided_fields and request.flow_shift is not None:
|
|
gen_params.extra_args["flow_shift"] = request.flow_shift
|
|
if "generate_sound" in provided_fields:
|
|
gen_params.extra_args["generate_sound"] = request.generate_sound
|
|
if "sound_duration" in provided_fields and request.sound_duration is not None:
|
|
gen_params.extra_args["sound_duration"] = request.sound_duration
|
|
|
|
# Apply model-specific extra parameters
|
|
if request.extra_params is not None:
|
|
if not isinstance(request.extra_params, dict):
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST.value,
|
|
detail="extra_params must be a JSON object/dict.",
|
|
)
|
|
if request.extra_params.get("preencode_mp4") and "preencode_batch_frames" in request.extra_params:
|
|
try:
|
|
normalize_preencode_batch_frames(request.extra_params["preencode_batch_frames"])
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST.value, detail=str(exc)) from exc
|
|
# Merge extra_params into extra_args
|
|
gen_params.extra_args.update(request.extra_params)
|
|
|
|
# Redact inline arrays when logging so RoboLab policy requests do
|
|
# not flood the server log with image/state payloads.
|
|
loggable = request.extra_params
|
|
redacted = {}
|
|
for key in ("action", "robot_obs", "observation"):
|
|
value = loggable.get(key)
|
|
if value is None:
|
|
continue
|
|
redacted[key] = (
|
|
f"<{type(value).__name__} len={len(value)}>"
|
|
if hasattr(value, "__len__")
|
|
else f"<{type(value).__name__}>"
|
|
)
|
|
if redacted:
|
|
loggable = {**loggable, **redacted}
|
|
logger.info("Applied extra_params: %s", loggable)
|
|
|
|
self._apply_lora(request.lora, gen_params)
|
|
|
|
logger.info(
|
|
"Video sampling params: steps=%s guidance=%s guidance_2=%s seed=%s",
|
|
gen_params.num_inference_steps,
|
|
gen_params.guidance_scale,
|
|
gen_params.guidance_scale_2,
|
|
gen_params.seed,
|
|
)
|
|
|
|
result = await self._run_generation(
|
|
prompt,
|
|
gen_params,
|
|
reference_id,
|
|
on_started=on_started,
|
|
)
|
|
multimodal_output = self._extract_multimodal_output(result)
|
|
metadata = multimodal_output.get("metadata") if isinstance(multimodal_output, dict) else {}
|
|
common_metadata = metadata.get("common") if isinstance(metadata, dict) else {}
|
|
action_only = bool(isinstance(common_metadata, dict) and common_metadata.get("action_only_output"))
|
|
videos = [{"action_only_output": True}] if action_only else self._extract_video_outputs(result)
|
|
audios = self._extract_audio_outputs(result, expected_count=len(videos))
|
|
actions = self._extract_action_outputs(result, expected_count=len(videos))
|
|
audio_sample_rate = self._resolve_audio_sample_rate(result)
|
|
model_fps = self._resolve_fps(result)
|
|
output_fps_base = (vp.fps if fps_provided else None) or model_fps or vp.fps or 24
|
|
output_fps = output_fps_base * self._resolve_video_fps_multiplier(result)
|
|
raw_metrics = getattr(result, "metrics", None) if request.return_stage_metrics else None
|
|
metrics = {str(key): value for key, value in raw_metrics.items()} if isinstance(raw_metrics, Mapping) else None
|
|
return VideoGenerationArtifacts(
|
|
videos=videos,
|
|
audios=audios,
|
|
actions=actions,
|
|
audio_sample_rate=audio_sample_rate,
|
|
output_fps=output_fps,
|
|
stage_durations=self._extract_stage_durations(result),
|
|
peak_memory_mb=self._extract_peak_memory_mb(result),
|
|
metrics=metrics,
|
|
)
|
|
|
|
async def generate_videos(
|
|
self,
|
|
request: VideoGenerationRequest,
|
|
reference_id: str,
|
|
*,
|
|
reference_image: ReferenceImage | None = None,
|
|
reference_video: ReferenceVideo | None = None,
|
|
reference_audio: ReferenceAudio | None = None,
|
|
latent_edit_input: LatentEditInput | None = None,
|
|
) -> VideoGenerationResponse:
|
|
artifacts = await self._run_and_extract(
|
|
request,
|
|
reference_id,
|
|
reference_image=reference_image,
|
|
reference_video=reference_video,
|
|
reference_audio=reference_audio,
|
|
latent_edit_input=latent_edit_input,
|
|
)
|
|
|
|
video_codec_options = {"preset": "ultrafast", "threads": "0"}
|
|
if request.extra_params is not None and isinstance(request.extra_params, dict):
|
|
if "video_codec_options" in request.extra_params:
|
|
video_codec_options = request.extra_params["video_codec_options"]
|
|
|
|
def encode_video_result(idx: int, video: Any) -> str:
|
|
if isinstance(video, bytes):
|
|
return base64.b64encode(video).decode("utf-8")
|
|
return encode_video_base64(
|
|
video,
|
|
fps=artifacts.output_fps,
|
|
audio=artifacts.audios[idx],
|
|
audio_sample_rate=artifacts.audio_sample_rate,
|
|
video_codec_options=video_codec_options,
|
|
frame_converter=self._video_frame_converter,
|
|
)
|
|
|
|
_t_encode_start = time.perf_counter()
|
|
video_data = [
|
|
VideoData(
|
|
b64_json=encode_video_result(idx, video),
|
|
action=artifacts.actions[idx],
|
|
)
|
|
for idx, video in enumerate(artifacts.videos)
|
|
]
|
|
_t_encode_ms = (time.perf_counter() - _t_encode_start) * 1000
|
|
logger.info("Video response encoding (MP4+base64): %.2f ms", _t_encode_ms)
|
|
return VideoGenerationResponse(
|
|
created=int(time.time()),
|
|
data=video_data,
|
|
stage_durations=artifacts.stage_durations,
|
|
peak_memory_mb=artifacts.peak_memory_mb,
|
|
)
|
|
|
|
async def generate_video_bytes(
|
|
self,
|
|
request: VideoGenerationRequest,
|
|
reference_id: str,
|
|
*,
|
|
reference_image: ReferenceImage | None = None,
|
|
reference_video: ReferenceVideo | None = None,
|
|
reference_audio: ReferenceAudio | None = None,
|
|
on_started: Callable[[], Awaitable[None]] | None = None,
|
|
latent_edit_input: LatentEditInput | None = None,
|
|
) -> tuple[bytes, dict[str, float], float, VideoAction | None, dict[str, object]]:
|
|
"""Generate a video and return raw MP4 bytes, bypassing base64 encoding."""
|
|
artifacts = await self._run_and_extract(
|
|
request,
|
|
reference_id,
|
|
reference_image=reference_image,
|
|
reference_video=reference_video,
|
|
reference_audio=reference_audio,
|
|
on_started=on_started,
|
|
latent_edit_input=latent_edit_input,
|
|
)
|
|
if len(artifacts.videos) > 1:
|
|
logger.warning(
|
|
"Video request %s generated %d outputs; returning only the first.",
|
|
reference_id,
|
|
len(artifacts.videos),
|
|
)
|
|
audio = artifacts.audios[0]
|
|
|
|
video_codec_options = {"preset": "ultrafast", "threads": "0"}
|
|
if request.extra_params is not None and isinstance(request.extra_params, dict):
|
|
if "video_codec_options" in request.extra_params:
|
|
video_codec_options = request.extra_params["video_codec_options"]
|
|
|
|
action = artifacts.actions[0]
|
|
video_metadata = _video_metadata_from_artifacts(artifacts)
|
|
if action is not None and isinstance(artifacts.videos[0], dict):
|
|
logger.info("Action-only video request %s completed; skipping MP4 encoding.", reference_id)
|
|
return b"", artifacts.stage_durations, artifacts.peak_memory_mb, action, video_metadata
|
|
|
|
_t_encode_start = time.perf_counter()
|
|
if isinstance(artifacts.videos[0], bytes):
|
|
video_bytes = artifacts.videos[0]
|
|
_t_encode_ms = (time.perf_counter() - _t_encode_start) * 1000
|
|
logger.info("Video response received pre-encoded MP4 bytes: %.2f ms", _t_encode_ms)
|
|
return (
|
|
video_bytes,
|
|
artifacts.stage_durations,
|
|
artifacts.peak_memory_mb,
|
|
artifacts.actions[0],
|
|
video_metadata,
|
|
)
|
|
video_bytes = _encode_video_bytes(
|
|
artifacts.videos[0],
|
|
fps=artifacts.output_fps,
|
|
**({"audio": audio, "audio_sample_rate": artifacts.audio_sample_rate} if audio is not None else {}),
|
|
video_codec_options=video_codec_options,
|
|
frame_converter=self._video_frame_converter,
|
|
)
|
|
_t_encode_ms = (time.perf_counter() - _t_encode_start) * 1000
|
|
logger.info("Video response encoding (MP4 bytes): %.2f ms", _t_encode_ms)
|
|
return video_bytes, artifacts.stage_durations, artifacts.peak_memory_mb, artifacts.actions[0], video_metadata
|
|
|
|
@staticmethod
|
|
def _resolve_video_fps_multiplier(result: object) -> int:
|
|
multimodal_output = OmniOpenAIServingVideo._extract_multimodal_output(result)
|
|
metadata = multimodal_output.get("metadata")
|
|
video_metadata = metadata.get("video") if isinstance(metadata, Mapping) else None
|
|
if isinstance(video_metadata, Mapping):
|
|
multiplier = video_metadata.get("video_fps_multiplier")
|
|
if multiplier is not None:
|
|
return int(multiplier)
|
|
return 1
|
|
|
|
@staticmethod
|
|
def _request_fps_provided(request: VideoGenerationRequest) -> bool:
|
|
if "fps" in request.model_fields_set and request.fps is not None:
|
|
return True
|
|
video_params = request.video_params
|
|
if video_params is None or "video_params" not in request.model_fields_set:
|
|
return False
|
|
return "fps" in video_params.model_fields_set and video_params.fps is not None
|
|
|
|
@staticmethod
|
|
def _request_num_frames_provided(request: VideoGenerationRequest) -> bool:
|
|
if "num_frames" in request.model_fields_set and request.num_frames is not None:
|
|
return True
|
|
video_params = request.video_params
|
|
if video_params is None or "video_params" not in request.model_fields_set:
|
|
return False
|
|
return "num_frames" in video_params.model_fields_set and video_params.num_frames is not None
|
|
|
|
def _resolve_default_sampling_params(self) -> OmniDiffusionSamplingParams:
|
|
default_sampling_params_list = getattr(self._engine_client, "default_sampling_params_list", None)
|
|
if default_sampling_params_list:
|
|
for params in default_sampling_params_list:
|
|
if isinstance(params, OmniDiffusionSamplingParams):
|
|
# Requests mutate sampling params in-place, including
|
|
# nested dict fields like extra_args. Deep-copy the stage
|
|
# defaults so one request cannot leak state into another.
|
|
return copy.deepcopy(params)
|
|
return OmniDiffusionSamplingParams()
|
|
|
|
@staticmethod
|
|
def _apply_lora(lora_body: Any, gen_params: OmniDiffusionSamplingParams) -> None:
|
|
try:
|
|
lora_request, lora_scale = parse_lora_request(lora_body)
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST.value,
|
|
detail=str(e),
|
|
) from e
|
|
|
|
if lora_request is None:
|
|
return
|
|
|
|
gen_params.lora_request = lora_request
|
|
if lora_scale is not None:
|
|
gen_params.lora_scale = lora_scale
|
|
|
|
async def _run_generation(
|
|
self,
|
|
prompt: OmniTextPrompt,
|
|
gen_params: OmniDiffusionSamplingParams,
|
|
request_id: str,
|
|
*,
|
|
on_started: Callable[[], Awaitable[None]] | None = None,
|
|
) -> object:
|
|
stage_configs = self._stage_configs or getattr(self._engine_client, "stage_configs", None)
|
|
|
|
if not stage_configs:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
|
|
detail="Stage configs not found. Start server with an omni diffusion model.",
|
|
)
|
|
|
|
# Video pipelines may use preparatory AR stages (for example, a
|
|
# vLLM-hosted text encoder) before the diffusion stage.
|
|
if not is_video_generation_pipeline(stage_configs):
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
|
|
detail="No final video output stage found in video generation pipeline.",
|
|
)
|
|
|
|
# Common generation logic for both paths
|
|
engine_client = cast(AsyncOmni, self._engine_client)
|
|
gen_params.emit_request_lifecycle = on_started is not None
|
|
sampling_params_list = build_stage_sampling_params_list(
|
|
list(stage_configs),
|
|
get_default_sampling_params_list(engine_client),
|
|
diffusion_params=gen_params,
|
|
replace_diffusion_params=True,
|
|
)
|
|
|
|
result = None
|
|
started_notified = False
|
|
async for output in engine_client.generate(
|
|
prompt=prompt,
|
|
request_id=request_id,
|
|
sampling_params_list=sampling_params_list,
|
|
):
|
|
if is_diffusion_request_started_output(output):
|
|
if on_started is not None and not started_notified:
|
|
await on_started()
|
|
started_notified = True
|
|
continue
|
|
result = output
|
|
|
|
if result is None:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
|
|
detail="No output generated from video generation pipeline.",
|
|
)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _normalize_video_outputs(videos: DiffusionPayloadValue | None) -> list[DiffusionPayloadValue]:
|
|
if videos is None:
|
|
return []
|
|
if hasattr(videos, "ndim") and videos.ndim == 5:
|
|
return [videos[i] for i in range(videos.shape[0])]
|
|
if isinstance(videos, list):
|
|
if not videos:
|
|
return []
|
|
first = videos[0]
|
|
if hasattr(first, "ndim") and first.ndim == 5:
|
|
flattened: list[DiffusionPayloadValue] = []
|
|
for item in videos:
|
|
if hasattr(item, "ndim") and item.ndim == 5:
|
|
flattened.extend([item[i] for i in range(item.shape[0])])
|
|
else:
|
|
flattened.append(item)
|
|
return flattened
|
|
if isinstance(first, list):
|
|
return videos
|
|
if hasattr(first, "ndim") and first.ndim == 3:
|
|
return [videos]
|
|
if isinstance(first, Image.Image):
|
|
return [videos]
|
|
return videos
|
|
return [videos]
|
|
|
|
def _extract_video_outputs(self, result: object) -> list[DiffusionPayloadValue]:
|
|
videos = None
|
|
if hasattr(result, "images") and result.images:
|
|
videos = result.images
|
|
if videos is None:
|
|
multimodal_output = getattr(result, "multimodal_output", None)
|
|
if isinstance(multimodal_output, Mapping):
|
|
videos = multimodal_output.get("video")
|
|
|
|
normalized = self._normalize_video_outputs(videos)
|
|
if not normalized:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
|
|
detail="No video outputs found in generation result.",
|
|
)
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _extract_audio_outputs(result: object, expected_count: int) -> list[DiffusionPayloadValue | None]:
|
|
audio = None
|
|
multimodal_output = getattr(result, "multimodal_output", None)
|
|
if isinstance(multimodal_output, Mapping):
|
|
audio = multimodal_output.get("audio")
|
|
|
|
if audio is None:
|
|
return [None] * expected_count
|
|
|
|
if isinstance(audio, (list, tuple)):
|
|
if len(audio) == expected_count and any(hasattr(item, "shape") or hasattr(item, "ndim") for item in audio):
|
|
return list(audio)
|
|
if expected_count == 1:
|
|
return [audio]
|
|
|
|
if hasattr(audio, "ndim") and getattr(audio, "ndim", None) is not None and audio.ndim > 1:
|
|
first_dim = getattr(audio, "shape", [0])[0]
|
|
if first_dim == expected_count:
|
|
return [audio[i] for i in range(expected_count)]
|
|
|
|
if expected_count == 1:
|
|
return [audio]
|
|
|
|
return [audio] + [None] * max(expected_count - 1, 0)
|
|
|
|
def _resolve_audio_sample_rate(self, result: object) -> int:
|
|
result_sample_rate = self._extract_audio_sample_rate_from_result(result)
|
|
if result_sample_rate is not None:
|
|
return result_sample_rate
|
|
|
|
model_config = getattr(self._engine_client, "model_config", None)
|
|
hf_config = getattr(model_config, "hf_config", None)
|
|
config_sample_rate = self._extract_audio_sample_rate_from_config(hf_config)
|
|
if config_sample_rate is not None:
|
|
return config_sample_rate
|
|
|
|
return 24000
|
|
|
|
@classmethod
|
|
def _extract_action_outputs(cls, result: object, expected_count: int) -> list[VideoAction | None]:
|
|
multimodal_output = cls._extract_multimodal_output(result)
|
|
if "actions" in multimodal_output:
|
|
action_payload = multimodal_output["actions"]
|
|
metadata = multimodal_output.get("metadata")
|
|
action_metadata = metadata.get("actions") if isinstance(metadata, Mapping) else {}
|
|
action_metadata = action_metadata if isinstance(action_metadata, Mapping) else {}
|
|
action_items = cls._split_action_payload(action_payload, expected_count)
|
|
return [
|
|
cls._make_video_action(action_item, action_metadata) if action_item is not None else None
|
|
for action_item in action_items
|
|
]
|
|
|
|
return [None] * expected_count
|
|
|
|
@staticmethod
|
|
def _extract_multimodal_output(result: object) -> DiffusionMultimodalOutput:
|
|
multimodal_output = getattr(result, "multimodal_output", None)
|
|
if isinstance(multimodal_output, Mapping):
|
|
return dict(multimodal_output)
|
|
|
|
request_output = result
|
|
if isinstance(request_output, dict):
|
|
multimodal_output = request_output.get("multimodal_output")
|
|
if multimodal_output is None:
|
|
multimodal_output = request_output.get("_multimodal_output")
|
|
elif request_output is not None:
|
|
multimodal_output = getattr(request_output, "multimodal_output", None)
|
|
if multimodal_output is None:
|
|
multimodal_output = getattr(request_output, "_multimodal_output", None)
|
|
|
|
return dict(multimodal_output) if isinstance(multimodal_output, Mapping) else {}
|
|
|
|
@classmethod
|
|
def _split_action_payload(
|
|
cls,
|
|
action: DiffusionPayloadValue,
|
|
expected_count: int,
|
|
) -> list[DiffusionPayloadValue | None]:
|
|
if expected_count <= 0:
|
|
return []
|
|
|
|
shape = cls._shape_of(action)
|
|
if len(shape) >= 3:
|
|
count = min(shape[0], expected_count)
|
|
actions = [cls._index_action(action, i) for i in range(count)]
|
|
actions.extend([None] * (expected_count - count))
|
|
return actions
|
|
|
|
return [action] + [None] * (expected_count - 1)
|
|
|
|
@classmethod
|
|
def _make_video_action(
|
|
cls,
|
|
action: DiffusionPayloadValue,
|
|
action_metadata: DiffusionMetadataMapping,
|
|
) -> VideoAction:
|
|
data = cls._to_jsonable(action)
|
|
if not isinstance(data, list):
|
|
data = [data]
|
|
|
|
action_mode = action_metadata.get("action_mode")
|
|
return VideoAction(
|
|
data=data,
|
|
shape=cls._shape_of(action),
|
|
dtype=cls._dtype_of(action),
|
|
raw_action_dim=cls._coerce_optional_int(action_metadata.get("raw_action_dim")),
|
|
action_mode=str(action_mode) if action_mode is not None else None,
|
|
domain_id=cls._coerce_optional_int(action_metadata.get("domain_id")),
|
|
)
|
|
|
|
@staticmethod
|
|
def _index_action(action: DiffusionPayloadValue, index: int) -> DiffusionPayloadValue | None:
|
|
try:
|
|
return action[index]
|
|
except (IndexError, KeyError, TypeError):
|
|
return None
|
|
|
|
@classmethod
|
|
def _to_jsonable(cls, value: DiffusionPayloadValue) -> DiffusionPayloadValue:
|
|
if hasattr(value, "detach"):
|
|
value = value.detach()
|
|
if hasattr(value, "cpu"):
|
|
value = value.cpu()
|
|
if hasattr(value, "tolist"):
|
|
return cls._to_jsonable(value.tolist())
|
|
if isinstance(value, (list, tuple)):
|
|
return [cls._to_jsonable(item) for item in value]
|
|
if hasattr(value, "item"):
|
|
try:
|
|
return value.item()
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return value
|
|
|
|
@classmethod
|
|
def _shape_of(cls, value: DiffusionPayloadValue) -> list[int]:
|
|
shape = getattr(value, "shape", None)
|
|
if shape is not None:
|
|
try:
|
|
return [int(dim) for dim in shape]
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if isinstance(value, (list, tuple)):
|
|
if not value:
|
|
return [0]
|
|
return [len(value)] + cls._shape_of(value[0])
|
|
return []
|
|
|
|
@staticmethod
|
|
def _dtype_of(value: DiffusionPayloadValue) -> str | None:
|
|
dtype = getattr(value, "dtype", None)
|
|
return str(dtype) if dtype is not None else None
|
|
|
|
@staticmethod
|
|
def _coerce_optional_int(value: object) -> int | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
value = value.item() if hasattr(value, "item") else value
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
@staticmethod
|
|
def _resolve_fps(result: object) -> float | None:
|
|
"""Extract fps from multimodal_output if the model reported it."""
|
|
multimodal_output = getattr(result, "multimodal_output", None)
|
|
if isinstance(multimodal_output, Mapping):
|
|
fps = multimodal_output.get("fps")
|
|
if fps is not None:
|
|
try:
|
|
fps_val = float(fps.item() if hasattr(fps, "item") else fps)
|
|
if math.isfinite(fps_val) and fps_val > 0:
|
|
return fps_val
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
request_output = result
|
|
if isinstance(request_output, dict):
|
|
mm = request_output.get("multimodal_output") or {}
|
|
if isinstance(mm, Mapping):
|
|
fps = mm.get("fps")
|
|
if fps is not None:
|
|
try:
|
|
fps_val = float(fps.item() if hasattr(fps, "item") else fps)
|
|
if math.isfinite(fps_val) and fps_val > 0:
|
|
return fps_val
|
|
except (TypeError, ValueError):
|
|
pass
|
|
elif hasattr(request_output, "multimodal_output"):
|
|
mm = getattr(request_output, "multimodal_output", None)
|
|
if isinstance(mm, Mapping):
|
|
fps = mm.get("fps")
|
|
if fps is not None:
|
|
try:
|
|
fps_val = float(fps.item() if hasattr(fps, "item") else fps)
|
|
if math.isfinite(fps_val) and fps_val > 0:
|
|
return fps_val
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
return None
|
|
|
|
@classmethod
|
|
def _extract_audio_sample_rate_from_result(cls, result: object) -> int | None:
|
|
multimodal_output = getattr(result, "multimodal_output", None)
|
|
if isinstance(multimodal_output, Mapping):
|
|
sample_rate = cls._coerce_audio_sample_rate(
|
|
multimodal_output.get("audio_sample_rate")
|
|
or multimodal_output.get("sample_rate")
|
|
or multimodal_output.get("sampling_rate")
|
|
or multimodal_output.get("sr")
|
|
)
|
|
if sample_rate is not None:
|
|
return sample_rate
|
|
|
|
request_output = result
|
|
if isinstance(request_output, dict):
|
|
multimodal_output = request_output.get("multimodal_output") or {}
|
|
if isinstance(multimodal_output, Mapping):
|
|
return cls._coerce_audio_sample_rate(
|
|
multimodal_output.get("audio_sample_rate")
|
|
or multimodal_output.get("sample_rate")
|
|
or multimodal_output.get("sampling_rate")
|
|
or multimodal_output.get("sr")
|
|
)
|
|
elif hasattr(request_output, "multimodal_output"):
|
|
multimodal_output = getattr(request_output, "multimodal_output", None)
|
|
if isinstance(multimodal_output, Mapping):
|
|
return cls._coerce_audio_sample_rate(
|
|
multimodal_output.get("audio_sample_rate")
|
|
or multimodal_output.get("sample_rate")
|
|
or multimodal_output.get("sampling_rate")
|
|
or multimodal_output.get("sr")
|
|
)
|
|
|
|
return None
|
|
|
|
@classmethod
|
|
def _extract_audio_sample_rate_from_config(cls, config: object) -> int | None:
|
|
if config is None:
|
|
return None
|
|
|
|
for attr_name in ("output_sampling_rate", "audio_sample_rate", "sample_rate", "sampling_rate"):
|
|
raw_value = config.get(attr_name) if isinstance(config, dict) else getattr(config, attr_name, None)
|
|
sample_rate = cls._coerce_audio_sample_rate(raw_value)
|
|
if sample_rate is not None:
|
|
return sample_rate
|
|
|
|
for component_name in ("vocoder", "audio_vae"):
|
|
component = (
|
|
config.get(component_name) if isinstance(config, dict) else getattr(config, component_name, None)
|
|
)
|
|
if component is None:
|
|
continue
|
|
|
|
sample_rate = cls._extract_audio_sample_rate_from_config(component)
|
|
if sample_rate is not None:
|
|
return sample_rate
|
|
|
|
component_config = (
|
|
component.get("config") if isinstance(component, dict) else getattr(component, "config", None)
|
|
)
|
|
sample_rate = cls._extract_audio_sample_rate_from_config(component_config)
|
|
if sample_rate is not None:
|
|
return sample_rate
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _coerce_audio_sample_rate(value: object) -> int | None:
|
|
if value is None:
|
|
return None
|
|
|
|
try:
|
|
sample_rate = value.item() if hasattr(value, "item") else value
|
|
sample_rate = int(sample_rate)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
return sample_rate if sample_rate > 0 else None
|
|
|
|
@staticmethod
|
|
def _extract_stage_durations(result: object) -> dict[str, float]:
|
|
stage_durations = getattr(result, "stage_durations", None)
|
|
return stage_durations if isinstance(stage_durations, dict) else {}
|
|
|
|
@staticmethod
|
|
def _extract_peak_memory_mb(result: object) -> float:
|
|
peak_memory_mb = getattr(result, "peak_memory_mb", 0.0)
|
|
try:
|
|
return float(peak_memory_mb or 0.0)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|