Signed-off-by: NumberWan <wantszkin2003@gmail.com> Signed-off-by: lishunyang12 <125541396+lishunyang12@users.noreply.github.com> Co-authored-by: lishunyang12 <125541396+lishunyang12@users.noreply.github.com>
153 lines
5.1 KiB
Python
153 lines
5.1 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Qwen-Omni streaming video WebSocket handler.
|
|
|
|
Accepts video frames incrementally via WebSocket, buffers them, and
|
|
generates text + optional audio responses using the Qwen3-Omni multi-stage
|
|
pipeline (thinker -> talker -> code2wav).
|
|
|
|
Protocol:
|
|
Client -> Server:
|
|
{"type": "session.config", ...} # Session config (sent once)
|
|
{"type": "video.frame", "data": "..."} # base64 JPEG/PNG frame
|
|
{"type": "audio.chunk", "data": "..."} # base64 PCM16 16kHz mono
|
|
{"type": "video.query", "text": "..."} # Submit query about buffered frames
|
|
{"type": "video.done"} # End of session
|
|
|
|
Server -> Client:
|
|
{"type": "response.start"}
|
|
{"type": "response.text.delta", "delta": "..."}
|
|
{"type": "response.text.done", "text": "..."}
|
|
{"type": "response.audio.delta", "data": "...", "format": "wav"}
|
|
{"type": "response.audio.done"}
|
|
{"type": "session.done"}
|
|
{"type": "error", "message": "..."}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from vllm_omni.entrypoints.openai.video_stream_base import (
|
|
_BAD_FRAME,
|
|
_DEFAULT_CONFIG_TIMEOUT,
|
|
_DEFAULT_IDLE_TIMEOUT,
|
|
StreamingVideoSessionConfig,
|
|
VideoStreamTurnTrigger,
|
|
)
|
|
from vllm_omni.entrypoints.openai.video_stream_base import (
|
|
OmniStreamingVideoHandler as OmniStreamingVideoHandlerBase,
|
|
)
|
|
|
|
__all__ = [
|
|
"QwenOmniStreamingVideoHandler",
|
|
"StreamingVideoSessionConfig",
|
|
"create_streaming_video_handler",
|
|
]
|
|
|
|
|
|
class QwenOmniStreamingVideoHandler(OmniStreamingVideoHandlerBase):
|
|
"""Qwen-Omni pipeline: manual ``video.query`` trigger and image_pil prompts."""
|
|
|
|
def should_trigger_turn(self, trigger: VideoStreamTurnTrigger) -> bool:
|
|
return False
|
|
|
|
def build_engine_prompt(
|
|
self,
|
|
config: StreamingVideoSessionConfig,
|
|
frame_buffer: list[str],
|
|
audio_buffer: bytearray,
|
|
message_history: list[dict[str, Any]],
|
|
query_text: str,
|
|
prewarmed_frames: dict[str, tuple[Any, str]],
|
|
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
n_buf = len(frame_buffer)
|
|
if n_buf <= config.num_frames:
|
|
frames = list(frame_buffer)
|
|
else:
|
|
stride = max(1, n_buf // config.num_frames)
|
|
idx = [i * stride for i in range(config.num_frames - 1)] + [n_buf - 1]
|
|
frames = [frame_buffer[i] for i in idx]
|
|
|
|
prewarmed = prewarmed_frames or {}
|
|
user_content: list[dict] = []
|
|
for frame_b64 in frames:
|
|
cached = prewarmed.get(frame_b64)
|
|
if cached is _BAD_FRAME:
|
|
continue
|
|
if cached is not None:
|
|
pil, pil_uuid = cached
|
|
user_content.append(
|
|
{
|
|
"type": "image_pil",
|
|
"image_pil": pil,
|
|
"uuid": pil_uuid,
|
|
}
|
|
)
|
|
else:
|
|
user_content.append(
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": f"data:image/jpeg;base64,{frame_b64}"},
|
|
}
|
|
)
|
|
|
|
if len(audio_buffer) > 0:
|
|
wav_b64 = self._pcm_to_wav_b64(bytes(audio_buffer))
|
|
user_content.append(
|
|
{
|
|
"type": "input_audio",
|
|
"input_audio": {
|
|
"data": wav_b64,
|
|
"format": "wav",
|
|
},
|
|
}
|
|
)
|
|
|
|
if query_text:
|
|
user_content.append({"type": "text", "text": query_text})
|
|
|
|
user_message: dict[str, Any] = {"role": "user", "content": user_content}
|
|
|
|
messages: list[dict[str, Any]] = []
|
|
if config.system_prompt:
|
|
messages.append({"role": "system", "content": config.system_prompt})
|
|
|
|
recent_history = message_history[-2:] if len(message_history) > 2 else message_history
|
|
for hist_msg in recent_history:
|
|
messages.append(self._text_only_message(hist_msg))
|
|
|
|
messages.append(user_message)
|
|
|
|
return messages, user_message
|
|
|
|
def on_turn_complete(
|
|
self,
|
|
message_history: list[dict[str, Any]],
|
|
user_message: dict[str, Any],
|
|
response_text: str,
|
|
) -> None:
|
|
message_history.append(user_message)
|
|
message_history.append({"role": "assistant", "content": response_text})
|
|
|
|
_build_messages = build_engine_prompt
|
|
|
|
|
|
def create_streaming_video_handler(
|
|
chat_service: Any,
|
|
idle_timeout: float = _DEFAULT_IDLE_TIMEOUT,
|
|
config_timeout: float = _DEFAULT_CONFIG_TIMEOUT,
|
|
engine_client: Any | None = None,
|
|
) -> OmniStreamingVideoHandlerBase:
|
|
"""Create the handler for ``/v1/video/chat/stream``.
|
|
|
|
Returns :class:`QwenOmniStreamingVideoHandler` today. Additional pipelines
|
|
can be selected here in follow-up PRs.
|
|
"""
|
|
return QwenOmniStreamingVideoHandler(
|
|
chat_service=chat_service,
|
|
idle_timeout=idle_timeout,
|
|
config_timeout=config_timeout,
|
|
engine_client=engine_client,
|
|
)
|