# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Video streaming session manager and WebSocket handler. Provides ``VideoStreamConfig``, ``VideoStreamSession`` (frame/audio buffer with EVS filtering), and ``VideoStreamHandler`` (WebSocket session loop). Builds standard ``ChatCompletionRequest`` so ``OmniOpenAIServingChat`` is reused with zero changes. """ from __future__ import annotations import asyncio import base64 import json from collections import deque from typing import Any from fastapi import WebSocket, WebSocketDisconnect from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ) from vllm.entrypoints.openai.engine.protocol import ErrorResponse from vllm.logger import init_logger from vllm_omni.entrypoints.openai.video_frame_filter import FrameSimilarityFilter logger = init_logger(__name__) _DEFAULT_IDLE_TIMEOUT = 60.0 _DEFAULT_CONFIG_TIMEOUT = 10.0 _MAX_FRAME_BYTES = 10 * 1024 * 1024 # 10 MB per frame class VideoStreamConfig: """Per-session configuration sent by the client in ``session.config``.""" __slots__ = ( "model", "modalities", "max_frames", "num_sample_frames", "evs_enabled", "evs_threshold", ) def __init__( self, model: str = "", modalities: list[str] | None = None, max_frames: int = 64, num_sample_frames: int = 16, evs_enabled: bool = True, evs_threshold: float = 0.95, ) -> None: self.model = model self.modalities = modalities if modalities is not None else ["text"] self.max_frames = max_frames self.num_sample_frames = num_sample_frames self.evs_enabled = evs_enabled self.evs_threshold = evs_threshold # JSON doesn't distinguish int/float, so float fields accept both. _FIELD_TYPES: dict[str, tuple[type, ...]] = { "model": (str,), "modalities": (list,), "max_frames": (int,), "num_sample_frames": (int,), "evs_enabled": (bool,), "evs_threshold": (int, float), } @classmethod def from_dict(cls, data: dict[str, Any]) -> VideoStreamConfig: known = set(cls.__slots__) filtered = {k: v for k, v in data.items() if k in known} for field, accepted in cls._FIELD_TYPES.items(): if field in filtered and not isinstance(filtered[field], accepted): names = "/".join(t.__name__ for t in accepted) raise TypeError(f"Invalid type for '{field}': expected {names}, got {type(filtered[field]).__name__}") return cls(**filtered) class VideoStreamSession: """Manages frame and audio buffers for a single streaming session. Frames are optionally filtered through ``FrameSimilarityFilter`` (EVS) before being stored in a fixed-size ring buffer. ``build_chat_request`` merges buffers into a ``ChatCompletionRequest`` with ``image_url`` (+ ``audio_url``) content blocks. """ def __init__(self, config: VideoStreamConfig) -> None: self._config = config self._frame_filter: FrameSimilarityFilter | None = ( FrameSimilarityFilter(threshold=config.evs_threshold) if config.evs_enabled else None ) self._frames: deque[bytes] = deque(maxlen=config.max_frames) self._audio_chunks: list[bytes] = [] def add_frame(self, jpeg_bytes: bytes) -> bool: """Add a JPEG frame after EVS filtering. Returns ``True`` if kept.""" if len(jpeg_bytes) > _MAX_FRAME_BYTES: raise ValueError(f"Frame too large: {len(jpeg_bytes)} bytes (limit {_MAX_FRAME_BYTES})") if self._frame_filter and not self._frame_filter.should_retain(jpeg_bytes): return False self._frames.append(jpeg_bytes) return True def sample_frames(self) -> list[bytes]: """Return up to ``num_sample_frames`` uniformly sampled frames.""" n = len(self._frames) k = min(n, self._config.num_sample_frames) if k == 0: return [] if k == 1: return [self._frames[-1]] if k >= n: return list(self._frames) indices = [int(i * (n - 1) / (k - 1)) for i in range(k)] return [self._frames[i] for i in indices] @property def frame_count(self) -> int: return len(self._frames) def add_audio_chunk(self, pcm_bytes: bytes) -> None: """Append raw PCM 16 kHz audio bytes.""" self._audio_chunks.append(pcm_bytes) def clear_audio(self) -> None: """Clear audio buffer after a query is submitted.""" self._audio_chunks.clear() @property def has_audio(self) -> bool: return bool(self._audio_chunks) def build_chat_request(self, query_text: str) -> ChatCompletionRequest: """Build a ``ChatCompletionRequest`` from the current buffers.""" sampled = self.sample_frames() content_parts: list[dict[str, Any]] = [] for frame in sampled: frame_b64 = base64.b64encode(frame).decode() content_parts.append( { "type": "image_url", "image_url": { "url": f"data:image/jpeg;base64,{frame_b64}", }, } ) if self.has_audio: combined_pcm = b"".join(self._audio_chunks) audio_b64 = base64.b64encode(combined_pcm).decode() content_parts.append( { "type": "audio_url", "audio_url": { "url": f"data:audio/L16;rate=16000;base64,{audio_b64}", }, } ) content_parts.append({"type": "text", "text": query_text}) messages: list[dict[str, Any]] = [ {"role": "user", "content": content_parts}, ] request_dict: dict[str, Any] = { "model": self._config.model, "messages": messages, "stream": True, } if self.has_audio: request_dict["mm_processor_kwargs"] = { "use_audio_in_video": True, } return ChatCompletionRequest(**request_dict) @property def evs_stats(self) -> dict[str, int | float] | None: return self._frame_filter.stats if self._frame_filter else None class VideoStreamHandler: """Drives a ``VideoStreamSession`` over a FastAPI ``WebSocket``. Instantiate once at server startup and call ``handle_session`` for each incoming connection. Uses two concurrent tasks (_reader + _processor) so frames keep arriving while a query is being processed. """ def __init__( self, chat_handler: Any, idle_timeout: float = _DEFAULT_IDLE_TIMEOUT, config_timeout: float = _DEFAULT_CONFIG_TIMEOUT, ) -> None: self._chat_handler = chat_handler self._idle_timeout = idle_timeout self._config_timeout = config_timeout async def handle_session(self, websocket: WebSocket) -> None: """Main session loop for one WebSocket connection.""" await websocket.accept() try: config = await self._receive_config(websocket) if config is None: return session = VideoStreamSession(config) logger.info( "Video stream session started: model=%s modalities=%s max_frames=%d evs=%s", config.model, config.modalities, config.max_frames, config.evs_enabled, ) msg_queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() async def _reader() -> None: try: while True: try: raw = await asyncio.wait_for( websocket.receive_text(), timeout=self._idle_timeout, ) except asyncio.TimeoutError: await self._send_error(websocket, "Idle timeout: no message received") await msg_queue.put(None) return try: msg = json.loads(raw) except json.JSONDecodeError: await self._send_error(websocket, "Invalid JSON") continue if not isinstance(msg, dict): await self._send_error(websocket, "Messages must be JSON objects") continue await msg_queue.put(msg) if msg.get("type") == "video.done": return except WebSocketDisconnect: await msg_queue.put(None) except Exception: await msg_queue.put(None) raise async def _processor() -> None: while True: msg = await msg_queue.get() if msg is None: return msg_type = msg.get("type") if msg_type == "video.frame": data_b64 = msg.get("data", "") try: jpeg_bytes = base64.b64decode(data_b64) except Exception: await self._send_error(websocket, "Invalid base64 in video.frame") continue try: session.add_frame(jpeg_bytes) except ValueError as exc: await self._send_error(websocket, str(exc)) continue elif msg_type == "audio.chunk": data_b64 = msg.get("data", "") try: pcm_bytes = base64.b64decode(data_b64) except Exception: await self._send_error(websocket, "Invalid base64 in audio.chunk") continue session.add_audio_chunk(pcm_bytes) elif msg_type == "video.query": query_text = msg.get("text", "") if not query_text: await self._send_error( websocket, "video.query requires a non-empty 'text' field", ) continue await self._handle_query(websocket, session, query_text) elif msg_type == "video.done": evs = session.evs_stats if evs is not None: await websocket.send_json({"type": "response.evs_stats", **evs}) await websocket.send_json({"type": "session.done"}) return else: await self._send_error(websocket, f"Unknown message type: {msg_type}") reader_task = asyncio.create_task(_reader()) try: await _processor() finally: reader_task.cancel() try: await reader_task except asyncio.CancelledError: pass except Exception: logger.debug( "Reader task raised an exception", exc_info=True, ) except WebSocketDisconnect: logger.info("Video stream: client disconnected") except Exception: logger.exception("Video stream session error") try: await self._send_error(websocket, "Internal server error") except Exception: pass async def _receive_config(self, websocket: WebSocket) -> VideoStreamConfig | None: try: raw = await asyncio.wait_for( websocket.receive_text(), timeout=self._config_timeout, ) except asyncio.TimeoutError: await self._send_error(websocket, "Timeout waiting for session.config") return None try: msg = json.loads(raw) except json.JSONDecodeError: await self._send_error(websocket, "Invalid JSON in session.config") return None if not isinstance(msg, dict) or msg.get("type") != "session.config": await self._send_error( websocket, f"Expected session.config, got: {msg.get('type') if isinstance(msg, dict) else type(msg).__name__}", ) return None try: return VideoStreamConfig.from_dict(msg) except TypeError as exc: await self._send_error(websocket, str(exc)) return None async def _handle_query( self, websocket: WebSocket, session: VideoStreamSession, query_text: str, ) -> None: """Build a ChatCompletionRequest, run it, and stream the response.""" if session.frame_count == 0: await self._send_error( websocket, "No frames available. Send video.frame before video.query.", ) return request = session.build_chat_request(query_text) await websocket.send_json({"type": "response.start"}) # After response.start, the protocol contract requires us to always # send response.text.done — even on error / exception paths. text_parts: list[str] = [] try: # raw_request=None: serving_chat.py guards with `if raw_request:` generator = await self._chat_handler.create_chat_completion(request, raw_request=None) if isinstance(generator, ErrorResponse): error_msg = generator.error.message if generator.error else "Unknown error" await self._send_error(websocket, error_msg) else: async for chunk in generator: if isinstance(chunk, str): for delta in self._parse_sse_deltas(chunk): text_parts.append(delta) await websocket.send_json( { "type": "response.text.delta", "delta": delta, } ) except Exception: logger.exception("Query failed") await self._send_error(websocket, "Query processing failed") full_text = "".join(text_parts) await websocket.send_json({"type": "response.text.done", "text": full_text}) session.clear_audio() @staticmethod def _parse_sse_deltas(chunk: str) -> list[str]: """Extract content deltas from SSE-formatted chunk.""" deltas: list[str] = [] for line in chunk.split("\n"): line = line.strip() if not line or not line.startswith("data: ") or line == "data: [DONE]": continue try: data = json.loads(line[6:]) delta = data.get("choices", [{}])[0].get("delta", {}).get("content", "") if delta: deltas.append(delta) except (json.JSONDecodeError, IndexError, AttributeError): pass return deltas @staticmethod async def _send_error(websocket: WebSocket, message: str) -> None: try: await websocket.send_json({"type": "error", "message": message}) except Exception: pass