diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml
index 809e3feef..65ad51057 100644
--- a/.buildkite/test-nightly.yml
+++ b/.buildkite/test-nightly.yml
@@ -1322,6 +1322,7 @@ steps:
- pytest -s -v tests/e2e/accuracy/hunyuanvideo15_i2v/test_hunyuanvideo15_i2v_video_similarity.py -m full_model --run-level full_model
- pytest -s -v tests/e2e/accuracy/test_ltx2_3_video_similarity.py -m full_model --run-level full_model
- pytest -s -v tests/e2e/accuracy/test_diffusers_backend_similarity.py -k '2v_matches_diffusers' -m full_model --run-level full_model
+ - pytest -s -v tests/e2e/accuracy/test_video_streaming_output_similarity.py -m full_model --run-level full_model
agents:
queue: "mithril-h100-pool"
plugins:
diff --git a/examples/online_serving/streaming_video_generation/README.md b/examples/online_serving/streaming_video_generation/README.md
new file mode 100644
index 000000000..1cfa29dfd
--- /dev/null
+++ b/examples/online_serving/streaming_video_generation/README.md
@@ -0,0 +1,94 @@
+# Streaming Video Generation
+
+This example uses the custom WebSocket endpoint `WS /v1/realtime/video` to receive a video byte stream as chunks are produced.
+It covers text-only video generation. Image/reference input is intentionally not included for now.
+
+## Start The Server
+
+Start a diffusion video model with streaming output enabled:
+
+```bash
+vllm serve BestWishYsh/Helios-Distilled \
+ --omni \
+ --diffusion-streaming-output \
+ --port 8000
+```
+
+The `--diffusion-streaming-output` CLI flag is forwarded as `streaming_output=True` in the default diffusion stage `engine_args`, then loaded by `OmniDiffusionConfig.from_kwargs()`.
+
+## WebSocket Protocol
+
+| Direction | Message | Format | Description |
+| --- | --- | --- | --- |
+| Client to server | `session.start` | JSON text: `{"type":"session.start","model":"...","prompt":"...","format":"m4s"}` | Starts generation. `format` is optional and accepts `m4s` (default). Sampling fields such as `width`, `height`, `fps`, `num_frames`, and `extra_params` may be included. |
+| Server to client | `video.start` | JSON text: `{"type":"video.start","request_id":"...","format":"m4s","config":{...}}` | Confirms the session and mirrors the accepted `format`. |
+| Server to client | Video chunk | Binary WebSocket frame | Fragmented MP4 (`m4s`) video bytes. |
+| Client to server | `session.stop` | JSON text: `{"type":"session.stop"}` | Requests cancellation of the active session. |
+| Client to server | `session.ping` | JSON text: `{"type":"session.ping"}` | Optional keepalive; refreshes the server stall clock. |
+| Server to client | `session.done` | JSON text: `{"type":"session.done","request_id":"...","chunks":3,"stopped":false}` | Ends a completed or stopped session. |
+| Server to client | `session.pong` | JSON text: `{"type":"session.pong"}` | Reply to `session.ping`. |
+| Server to client | `error` | JSON text: `{"type":"error","message":"..."}` | Reports invalid input, unsupported formats, generation failures, control-message errors, or stall timeout. |
+
+During generation the client normally sends only `session.start` and then receives binary chunks; silence on the client socket is expected. The server closes the session with a stall error only when there is no engine progress and no `session.ping` for about 60 seconds.
+
+## Install Client Dependency
+
+```bash
+pip install websockets
+# For the Gradio demo:
+pip install 'vllm-omni[demo]' websockets
+```
+
+## Run The Client
+
+```bash
+python streaming_video_client.py \
+ --host 127.0.0.1 \
+ --port 8000 \
+ --model BestWishYsh/Helios-Distilled \
+ --prompt "A serene lakeside sunrise with mist over the water." \
+ --width 640 \
+ --height 384 \
+ --fps 16 \
+ --num-frames 99 \
+ --guidance-scale 1.0 \
+ --seed 42 \
+ --output helios_stream.mp4
+```
+
+The client sends one `session.start` message, prints each received binary video chunk with its byte size and elapsed time, and saves the received bytes to `--output` after `session.done`.
+The client remuxes the gathered stream to a regular progressive MP4 file so that local playback knows the video duration.
+
+## Run The Gradio Demo
+
+```bash
+python gradio_demo.py \
+ --host 127.0.0.1 \
+ --port 7860
+```
+
+The Gradio demo requests fMP4 (`m4s`) chunks and appends them directly in the browser with a Media Source Extensions player.
+
+## Model Choice
+
+### Helios
+
+The example uses `BestWishYsh/Helios-Distilled` model by default.
+
+To ensure streaming-level generation speed, `pyramid_num_inference_steps_list` is suggested to be as low as `[1, 1, 1]`. Both example clients uses the following Helios-Distilled preset by default:
+
+```json
+{
+ "is_enable_stage2": true,
+ "pyramid_num_stages": 3,
+ "pyramid_num_inference_steps_list": [1, 1, 1],
+ "is_amplify_first_chunk": true
+}
+```
+
+Disable it in the CLI example with `--no-helios-distilled-preset`, or override/extend it with `--extra-params`:
+
+```bash
+python streaming_video_client.py \
+ --extra-params '{"pyramid_num_inference_steps_list":[2, 2, 2]}'
+```
diff --git a/examples/online_serving/streaming_video_generation/gradio_demo.py b/examples/online_serving/streaming_video_generation/gradio_demo.py
new file mode 100644
index 000000000..2f7a301f1
--- /dev/null
+++ b/examples/online_serving/streaming_video_generation/gradio_demo.py
@@ -0,0 +1,301 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Gradio demo for `/v1/realtime/video`.
+
+Usage:
+ python gradio_demo.py --host 127.0.0.1 --port 7860
+
+Start a vLLM-Omni server with streaming output enabled first, for example:
+ vllm serve BestWishYsh/Helios-Distilled --omni --diffusion-streaming-output --port 8000
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Any
+
+try:
+ import gradio as gr
+except ImportError:
+ raise ImportError("gradio is required to run this demo. Install it with: pip install 'vllm-omni[demo]'") from None
+
+DEFAULT_PROMPT = "A vibrant tropical fish swimming gracefully among colorful coral reefs in a clear, turquoise ocean. The fish has bright blue and yellow scales with a small, distinctive orange spot on its side, its fins moving fluidly. The coral reefs are alive with a variety of marine life, including small schools of colorful fish and sea turtles gliding by. The water is crystal clear, allowing for a view of the sandy ocean floor below. The reef itself is adorned with a mix of hard and soft corals in shades of red, orange, and green. The photo captures the fish from a slightly elevated angle, emphasizing its lively movements and the vivid colors of its surroundings. A close-up shot with dynamic movement."
+VIDEO_STREAM_VIEW_HTML = Path(__file__).with_name("video-stream-view.html")
+VIDEO_STREAM_VIEW_JS = Path(__file__).with_name("video-stream-view.js")
+
+
+def _maybe_set(payload: dict[str, Any], key: str, value: Any) -> None:
+ if value is None:
+ return
+ if isinstance(value, str) and not value.strip():
+ return
+ payload[key] = value
+
+
+def _optional_int(value: int | float | None) -> int | None:
+ return None if value is None else int(value)
+
+
+def _optional_float(value: int | float | None) -> float | None:
+ return None if value is None else float(value)
+
+
+def _build_session_start(
+ *,
+ model: str,
+ prompt: str,
+ negative_prompt: str,
+ width: int | float | None,
+ height: int | float | None,
+ fps: int | float | None,
+ num_frames: int | float | None,
+ num_inference_steps: int | float | None,
+ guidance_scale: int | float | None,
+ guidance_scale_2: int | float | None,
+ boundary_ratio: int | float | None,
+ flow_shift: int | float | None,
+ true_cfg_scale: int | float | None,
+ seed: int | float | None,
+) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "type": "session.start",
+ "model": model,
+ "prompt": prompt,
+ "format": "m4s",
+ }
+
+ _maybe_set(payload, "negative_prompt", negative_prompt)
+ _maybe_set(payload, "width", _optional_int(width))
+ _maybe_set(payload, "height", _optional_int(height))
+ _maybe_set(payload, "fps", _optional_int(fps))
+ _maybe_set(payload, "num_frames", _optional_int(num_frames))
+ _maybe_set(payload, "num_inference_steps", _optional_int(num_inference_steps))
+ _maybe_set(payload, "guidance_scale", _optional_float(guidance_scale))
+ _maybe_set(payload, "guidance_scale_2", _optional_float(guidance_scale_2))
+ _maybe_set(payload, "boundary_ratio", _optional_float(boundary_ratio))
+ _maybe_set(payload, "flow_shift", _optional_float(flow_shift))
+ _maybe_set(payload, "true_cfg_scale", _optional_float(true_cfg_scale))
+ _maybe_set(payload, "seed", _optional_int(seed))
+
+ if "Helios" in model:
+ payload["extra_params"] = {
+ "is_enable_stage2": True,
+ "pyramid_num_stages": 3,
+ "pyramid_num_inference_steps_list": [1, 1, 1],
+ "is_amplify_first_chunk": True,
+ }
+
+ return payload
+
+
+def _set_optional_field_enabled(enabled: bool) -> Any:
+ return gr.update(interactive=enabled)
+
+
+def build_browser_stream_config(
+ host: str,
+ port: int | float,
+ model: str,
+ prompt: str,
+ negative_prompt: str,
+ width: int | float | None,
+ height: int | float | None,
+ fps: int | float,
+ num_frames: int | float | None,
+ num_inference_steps: int | float | None,
+ include_guidance_scale: bool,
+ guidance_scale: int | float | None,
+ include_guidance_scale_2: bool,
+ guidance_scale_2: int | float | None,
+ include_seed: bool,
+ seed: int | float | None,
+ include_boundary_ratio: bool,
+ boundary_ratio: int | float | None,
+ include_flow_shift: bool,
+ flow_shift: int | float | None,
+ include_true_cfg_scale: bool,
+ true_cfg_scale: int | float | None,
+) -> tuple[str, Any]:
+ """Build the browser-side WebSocket request config for the HTML/MSE player."""
+ if not model.strip():
+ raise gr.Error("Model is required.")
+ if not prompt.strip():
+ raise gr.Error("Prompt is required.")
+
+ payload = _build_session_start(
+ model=model.strip(),
+ prompt=prompt.strip(),
+ negative_prompt=negative_prompt,
+ width=width,
+ height=height,
+ fps=fps,
+ num_frames=num_frames,
+ num_inference_steps=num_inference_steps,
+ guidance_scale=guidance_scale if include_guidance_scale else None,
+ guidance_scale_2=guidance_scale_2 if include_guidance_scale_2 else None,
+ boundary_ratio=boundary_ratio if include_boundary_ratio else None,
+ flow_shift=flow_shift if include_flow_shift else None,
+ true_cfg_scale=true_cfg_scale if include_true_cfg_scale else None,
+ seed=seed if include_seed else None,
+ )
+ config = {
+ "url": f"ws://{host}:{int(port)}/v1/realtime/video",
+ "payload": payload,
+ }
+ return json.dumps(config, ensure_ascii=False), gr.update(value="Streaming...", interactive=False)
+
+
+def create_demo() -> gr.Blocks:
+ with gr.Blocks(title="Streaming Video Generation") as demo:
+ gr.Markdown("# Streaming Video Generation")
+ gr.Markdown(
+ "Connects to `WS /v1/realtime/video` in the browser and appends fMP4 chunks "
+ "directly to a Media Source Extensions video player."
+ )
+
+ with gr.Row():
+ with gr.Column(scale=1):
+ host = gr.Textbox(label="Server Host", value="127.0.0.1")
+ port = gr.Number(label="Server Port", value=8000, precision=0)
+ model = gr.Textbox(label="Model", value="BestWishYsh/Helios-Distilled")
+ prompt = gr.Textbox(label="Prompt", value=DEFAULT_PROMPT, lines=3)
+ negative_prompt = gr.Textbox(label="Negative Prompt", value="", lines=2)
+
+ with gr.Row():
+ width = gr.Number(label="Width", value=640, precision=0)
+ height = gr.Number(label="Height", value=384, precision=0)
+
+ with gr.Row():
+ fps = gr.Number(label="FPS", value=16, precision=0)
+ num_frames = gr.Number(label="Num Frames", value=132, precision=0)
+ num_inference_steps = gr.Number(label="Num Inference Steps", value=50, precision=0)
+
+ with gr.Row():
+ with gr.Column():
+ guidance_scale = gr.Number(
+ label="Guidance Scale",
+ value=1.0,
+ interactive=True,
+ scale=4,
+ )
+ include_guidance_scale = gr.Checkbox(label="Send", value=True, scale=1, min_width=70)
+ with gr.Column():
+ guidance_scale_2 = gr.Number(
+ label="Guidance Scale 2",
+ value=None,
+ interactive=False,
+ scale=4,
+ )
+ include_guidance_scale_2 = gr.Checkbox(label="Send", value=False, scale=1, min_width=70)
+ with gr.Column():
+ true_cfg_scale = gr.Number(
+ label="True CFG Scale",
+ value=None,
+ interactive=False,
+ scale=4,
+ )
+ include_true_cfg_scale = gr.Checkbox(label="Send", value=False, scale=1, min_width=70)
+ # I want it to wrap after 3, but it auto wrap after 2. I'll rather fully delegate to auto wrapping.
+ with gr.Column():
+ boundary_ratio = gr.Number(
+ label="Boundary Ratio",
+ value=None,
+ interactive=False,
+ scale=4,
+ )
+ include_boundary_ratio = gr.Checkbox(label="Send", value=False, scale=1, min_width=70)
+ with gr.Column():
+ flow_shift = gr.Number(label="Flow Shift", value=None, interactive=False, scale=4)
+ include_flow_shift = gr.Checkbox(label="Send", value=False, scale=1, min_width=70)
+ with gr.Column():
+ seed = gr.Number(label="Seed", value=42, precision=0, interactive=True, scale=4)
+ include_seed = gr.Checkbox(label="Send", value=True, scale=1, min_width=70)
+
+ with gr.Row():
+ start_button = gr.Button("Start", variant="primary", elem_id="streaming-video-start")
+ hidden_stream_config = gr.Textbox(value="", visible=False)
+
+ with gr.Column(scale=1):
+ gr.HTML(
+ VIDEO_STREAM_VIEW_HTML.read_text(encoding="utf-8"),
+ label="Streaming Preview",
+ js_on_load=VIDEO_STREAM_VIEW_JS.read_text(encoding="utf-8"),
+ )
+
+ inputs = [
+ host,
+ port,
+ model,
+ prompt,
+ negative_prompt,
+ width,
+ height,
+ fps,
+ num_frames,
+ num_inference_steps,
+ include_guidance_scale,
+ guidance_scale,
+ include_guidance_scale_2,
+ guidance_scale_2,
+ include_seed,
+ seed,
+ include_boundary_ratio,
+ boundary_ratio,
+ include_flow_shift,
+ flow_shift,
+ include_true_cfg_scale,
+ true_cfg_scale,
+ ]
+ for toggle, field in [
+ (include_guidance_scale, guidance_scale),
+ (include_guidance_scale_2, guidance_scale_2),
+ (include_seed, seed),
+ (include_boundary_ratio, boundary_ratio),
+ (include_flow_shift, flow_shift),
+ (include_true_cfg_scale, true_cfg_scale),
+ ]:
+ toggle.change(
+ fn=_set_optional_field_enabled,
+ inputs=toggle,
+ outputs=field,
+ )
+ start_button.click(
+ fn=build_browser_stream_config,
+ inputs=inputs,
+ outputs=[hidden_stream_config, start_button],
+ ).then(
+ fn=lambda config: config,
+ inputs=hidden_stream_config,
+ outputs=hidden_stream_config,
+ js="""
+ (config) => {
+ if (window.vllmStreamingVideoStart) {
+ window.vllmStreamingVideoStart(config);
+ }
+ return config;
+ }
+ """,
+ )
+
+ return demo
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Streaming video generation Gradio demo")
+ parser.add_argument("--host", default="127.0.0.1", help="Host/IP for gradio launch")
+ parser.add_argument("--port", type=int, default=7860, help="Port for gradio launch")
+ parser.add_argument("--share", action="store_true", help="Create a public Gradio link")
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ demo = create_demo()
+ demo.queue().launch(server_name=args.host, server_port=args.port, share=args.share)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/online_serving/streaming_video_generation/streaming_video_client.py b/examples/online_serving/streaming_video_generation/streaming_video_client.py
new file mode 100644
index 000000000..c77113420
--- /dev/null
+++ b/examples/online_serving/streaming_video_generation/streaming_video_client.py
@@ -0,0 +1,279 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Text-only WebSocket client for `/v1/realtime/video`.
+
+The client prints a line for every binary video chunk it receives and writes
+the received bytes to disk when the session finishes or is interrupted.
+Image/reference input is intentionally not supported in this example yet.
+
+Requirements:
+ pip install websockets
+"""
+
+import argparse
+import asyncio
+import io
+import json
+import os
+import time
+from pathlib import Path
+from typing import Any
+
+import av
+
+try:
+ from websockets.asyncio.client import connect # pyright: ignore[reportMissingImports]
+except ImportError:
+ try:
+ from websockets import connect # pyright: ignore[reportMissingImports]
+ except ImportError:
+ print("Please install websockets: pip install websockets")
+ raise SystemExit(1)
+
+
+HELIOS_DISTILLED_EXTRA_PARAMS = {
+ "is_enable_stage2": True,
+ "pyramid_num_stages": 3,
+ "pyramid_num_inference_steps_list": [1, 1, 1],
+ "is_amplify_first_chunk": True,
+}
+
+
+def _json_object(value: str, *, argument_name: str) -> dict[str, Any]:
+ try:
+ parsed = json.loads(value)
+ except json.JSONDecodeError as exc:
+ raise argparse.ArgumentTypeError(f"{argument_name} must be valid JSON: {exc}") from exc
+ if not isinstance(parsed, dict):
+ raise argparse.ArgumentTypeError(f"{argument_name} must be a JSON object")
+ return parsed
+
+
+def _maybe_set(payload: dict[str, Any], key: str, value: Any) -> None:
+ if value is not None:
+ payload[key] = value
+
+
+def build_session_start(args: argparse.Namespace) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "type": "session.start",
+ "model": args.model,
+ "prompt": args.prompt,
+ }
+
+ _maybe_set(payload, "negative_prompt", args.negative_prompt)
+ _maybe_set(payload, "width", args.width)
+ _maybe_set(payload, "height", args.height)
+ _maybe_set(payload, "size", args.size)
+ _maybe_set(payload, "seconds", args.seconds)
+ _maybe_set(payload, "fps", args.fps)
+ _maybe_set(payload, "num_frames", args.num_frames)
+ _maybe_set(payload, "num_inference_steps", args.num_inference_steps)
+ _maybe_set(payload, "guidance_scale", args.guidance_scale)
+ _maybe_set(payload, "guidance_scale_2", args.guidance_scale_2)
+ _maybe_set(payload, "boundary_ratio", args.boundary_ratio)
+ _maybe_set(payload, "flow_shift", args.flow_shift)
+ _maybe_set(payload, "true_cfg_scale", args.true_cfg_scale)
+ _maybe_set(payload, "seed", args.seed)
+
+ extra_params: dict[str, Any] = {}
+ if "Helios-Distilled" in args.model and args.helios_distilled_preset:
+ extra_params.update(HELIOS_DISTILLED_EXTRA_PARAMS)
+ if args.extra_params:
+ extra_params.update(args.extra_params)
+ if extra_params:
+ payload["extra_params"] = extra_params
+
+ return payload
+
+
+def _count_decoded_frames(video_bytes: bytes, *, stream_format: str) -> int | None:
+ """Return the number of decodable video frames in a streamed byte sequence, if known."""
+ if not video_bytes:
+ return 0
+
+ av_format = "mp4" if stream_format == "m4s" else None
+ try:
+ with av.open(io.BytesIO(video_bytes), format=av_format, mode="r") as container:
+ stream = container.streams.video[0]
+ return sum(1 for _ in container.decode(stream))
+ except Exception:
+ return None
+
+
+def save_video(
+ chunks: list[bytes],
+ output_path: Path,
+ *,
+ fps: float,
+ stream_format: str,
+) -> None:
+ if not chunks:
+ print("No video chunks received; nothing to save.")
+ return
+
+ from vllm_omni.diffusion.utils.media_utils import finalize_streaming_video_bytes
+
+ streamed_bytes = b"".join(chunks)
+ playback_bytes = finalize_streaming_video_bytes(
+ streamed_bytes,
+ input_format="m4s",
+ fps=fps,
+ )
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ tmp_path = output_path.with_suffix(output_path.suffix + ".tmp")
+ with tmp_path.open("wb") as f:
+ f.write(playback_bytes)
+ os.replace(tmp_path, output_path)
+ print(
+ f"Saved {len(chunks)} streamed chunk(s), {len(streamed_bytes)} streamed bytes "
+ f"-> {len(playback_bytes)} playback bytes at {output_path}"
+ )
+
+
+async def stream_video(args: argparse.Namespace) -> None:
+ payload = build_session_start(args)
+ output_path = Path(args.output)
+ url = f"ws://{args.host}:{args.port}/v1/realtime/video"
+ chunks: list[bytes] = []
+ total_bytes = 0
+ total_frames = 0
+ accumulated_stream = bytearray()
+ stream_format = "m4s"
+ done = False
+ ws = None
+
+ try:
+ async with connect(url, max_size=None) as websocket:
+ ws = websocket
+ await websocket.send(json.dumps(payload))
+ print(f"Connected: {url}")
+ print(f"Sent session.start: {json.dumps(payload, ensure_ascii=False)}")
+ started_at = time.perf_counter()
+ last_chunk_at = started_at
+
+ while True:
+ message = await websocket.recv()
+ now = time.perf_counter()
+
+ if isinstance(message, bytes):
+ chunks.append(message)
+ chunk_bytes = len(message)
+ chunk_elapsed = now - last_chunk_at
+ last_chunk_at = now
+
+ total_bytes += chunk_bytes
+ accumulated_stream.extend(message)
+ decoded_total = _count_decoded_frames(bytes(accumulated_stream), stream_format=stream_format)
+ if decoded_total is not None:
+ chunk_frames = max(decoded_total - total_frames, 0)
+ total_frames = decoded_total
+ else:
+ chunk_frames = 0
+
+ total_elapsed = now - started_at
+ print(
+ f"[chunk {len(chunks):03d}] "
+ f"bytes={chunk_bytes} "
+ f"frames={chunk_frames} "
+ f"elapsed={chunk_elapsed:.2f}s "
+ f"total_bytes={total_bytes} "
+ f"total_frames={total_frames} "
+ f"total_elapsed={total_elapsed:.2f}s"
+ )
+ continue
+
+ msg = json.loads(message)
+ msg_type = msg.get("type")
+ if msg_type == "video.start":
+ stream_format = msg.get("format") or stream_format
+ print(f"Video session started: request_id={msg.get('request_id')} format={msg.get('format')}")
+ elif msg_type == "session.done":
+ print(f"Session complete: {json.dumps(msg, ensure_ascii=False)}")
+ done = True
+ break
+ elif msg_type == "error":
+ print(f"ERROR: {msg.get('message', msg)}")
+ else:
+ print(f"Received control message: {msg}")
+ except (KeyboardInterrupt, asyncio.CancelledError):
+ print("\nInterrupted. Asking server to stop the active session...")
+ if ws is not None:
+ try:
+ await ws.send(json.dumps({"type": "session.stop"}))
+ await ws.close(code=1000, reason="client interrupted")
+ except Exception:
+ pass
+ raise
+ finally:
+ print(
+ "Saving video... (May take a while. Remuxing concatenated chunks into one progressive MP4 file with total duration metadata in file header)"
+ )
+ save_video(
+ chunks,
+ output_path,
+ fps=float(payload.get("fps") or args.fps),
+ stream_format=stream_format,
+ )
+ if not done:
+ print("Saved partial output because the stream did not finish normally.")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Streaming video generation WebSocket client")
+ parser.add_argument("--host", default="127.0.0.1", help="API server host")
+ parser.add_argument("--port", type=int, default=8000, help="API server port")
+ parser.add_argument(
+ "--model",
+ default="BestWishYsh/Helios-Distilled",
+ help="Model name served by the API server",
+ )
+ parser.add_argument(
+ "--prompt",
+ default="A vibrant tropical fish swimming gracefully among colorful coral reefs in a clear, turquoise ocean. The fish has bright blue and yellow scales with a small, distinctive orange spot on its side, its fins moving fluidly. The coral reefs are alive with a variety of marine life, including small schools of colorful fish and sea turtles gliding by. The water is crystal clear, allowing for a view of the sandy ocean floor below. The reef itself is adorned with a mix of hard and soft corals in shades of red, orange, and green. The photo captures the fish from a slightly elevated angle, emphasizing its lively movements and the vivid colors of its surroundings. A close-up shot with dynamic movement.",
+ help="Text prompt for video generation",
+ )
+ parser.add_argument("--output", default="streaming_video_output.mp4", help="Path for the received video bytes")
+ parser.add_argument("--width", type=int, default=640, help="Video width")
+ parser.add_argument("--height", type=int, default=384, help="Video height")
+ parser.add_argument("--size", default=None, help="Video size as WIDTHxHEIGHT; alternative to width/height")
+ parser.add_argument("--seconds", default=None, help="Clip duration in seconds")
+ parser.add_argument("--fps", type=int, default=16, help="Frames per second")
+ parser.add_argument("--num-frames", type=int, default=99, help="Number of generated frames")
+
+ parser.add_argument("--negative-prompt", default=None, help="Negative prompt")
+ parser.add_argument("--num-inference-steps", type=int, default=None, help="Number of diffusion steps")
+ parser.add_argument("--guidance-scale", type=float, default=1.0, help="Classifier-free guidance scale")
+ parser.add_argument("--guidance-scale-2", type=float, default=None, help="High-noise CFG scale for video models")
+ parser.add_argument("--boundary-ratio", type=float, default=None, help="Boundary split ratio for video models")
+ parser.add_argument("--flow-shift", type=float, default=None, help="Scheduler flow_shift for video models")
+ parser.add_argument("--true-cfg-scale", type=float, default=None, help="True CFG scale for models that support it")
+ parser.add_argument("--seed", type=int, default=42, help="Random seed")
+
+ parser.add_argument(
+ "--helios-distilled-preset",
+ action=argparse.BooleanOptionalAction,
+ default=True,
+ help="Send the Helios-Distilled extra_params used by the bundled example.",
+ )
+ parser.add_argument(
+ "--extra-params",
+ type=lambda value: _json_object(value, argument_name="--extra-params"),
+ default=None,
+ help="JSON object merged into extra_params; overrides preset keys on conflict.",
+ )
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ try:
+ asyncio.run(stream_video(args))
+ except KeyboardInterrupt:
+ pass
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/online_serving/streaming_video_generation/video-stream-view.html b/examples/online_serving/streaming_video_generation/video-stream-view.html
new file mode 100644
index 000000000..bbef95c69
--- /dev/null
+++ b/examples/online_serving/streaming_video_generation/video-stream-view.html
@@ -0,0 +1,11 @@
+
+
diff --git a/examples/online_serving/streaming_video_generation/video-stream-view.js b/examples/online_serving/streaming_video_generation/video-stream-view.js
new file mode 100644
index 000000000..1b0a5024b
--- /dev/null
+++ b/examples/online_serving/streaming_video_generation/video-stream-view.js
@@ -0,0 +1,198 @@
+/**
+ * This file and the corresponding HTML file are needed to display a streamable video view in the Gradio demo.
+ * Gradio's built-in video player does not support modern M4S streaming format.
+ */
+(function () {
+ const playerId = "vllm-streaming-video-player";
+ const statusId = "vllm-streaming-video-status";
+ const logId = "vllm-streaming-video-log";
+ let ws = null;
+ let mediaSource = null;
+ let sourceBuffer = null;
+ let queue = [];
+ let chunkCount = 0;
+ let totalBytes = 0;
+ let done = false;
+ let objectUrl = null;
+
+ function el(id) {
+ return document.getElementById(id);
+ }
+
+ function setStatus(text) {
+ const node = el(statusId);
+ if (node) node.textContent = text;
+ }
+
+ function log(line) {
+ const node = el(logId);
+ if (!node) return;
+ node.textContent += "\n" + line;
+ node.scrollTop = node.scrollHeight;
+ }
+
+ function setStartButton(disabled, label) {
+ const btn = document.querySelector("#streaming-video-start button") || document.getElementById("streaming-video-start");
+ if (!btn) return;
+ btn.disabled = disabled;
+ btn.textContent = label;
+ }
+
+ function chooseMime() {
+ const candidates = [
+ 'video/mp4; codecs="avc1.42E01E"',
+ 'video/mp4; codecs="avc1.4D401F"',
+ 'video/mp4; codecs="avc1.64001F"',
+ "video/mp4"
+ ];
+ for (const mime of candidates) {
+ if (window.MediaSource && MediaSource.isTypeSupported(mime)) return mime;
+ }
+ return null;
+ }
+
+ function pump() {
+ if (!sourceBuffer || sourceBuffer.updating || queue.length === 0) return;
+ try {
+ sourceBuffer.appendBuffer(queue.shift());
+ } catch (err) {
+ done = true;
+ setStatus("SourceBuffer error");
+ setStartButton(false, "Restart");
+ log("SourceBuffer append error: " + err.message);
+ finishStream();
+ }
+ }
+
+ function finishStream() {
+ if (!mediaSource || mediaSource.readyState !== "open") return;
+ if (sourceBuffer && sourceBuffer.updating) return;
+ if (queue.length > 0) return;
+ try {
+ mediaSource.endOfStream();
+ } catch (_) { }
+ }
+
+ function stopCurrent() {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ try { ws.send(JSON.stringify({ type: "session.stop" })); } catch (_) { }
+ try { ws.close(); } catch (_) { }
+ }
+ ws = null;
+ sourceBuffer = null;
+ mediaSource = null;
+ queue = [];
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ objectUrl = null;
+ }
+
+ window.vllmStreamingVideoStart = function (configJson) {
+ stopCurrent();
+ chunkCount = 0;
+ totalBytes = 0;
+ done = false;
+ const logNode = el(logId);
+ if (logNode) logNode.textContent = "Starting...";
+ setStatus("Starting...");
+
+ let config;
+ try {
+ config = JSON.parse(configJson);
+ } catch (err) {
+ setStatus("Invalid request");
+ setStartButton(false, "Restart");
+ log("Invalid request config: " + err.message);
+ return configJson;
+ }
+
+ const mime = chooseMime();
+ if (!mime) {
+ setStatus("MSE unsupported");
+ setStartButton(false, "Restart");
+ log("This browser does not support MP4 Media Source Extensions playback.");
+ return configJson;
+ }
+
+ const video = el(playerId);
+ if (!video) return configJson;
+ mediaSource = new MediaSource();
+ objectUrl = URL.createObjectURL(mediaSource);
+ video.src = objectUrl;
+
+ mediaSource.addEventListener("sourceopen", () => {
+ try {
+ sourceBuffer = mediaSource.addSourceBuffer(mime);
+ sourceBuffer.mode = "segments";
+ sourceBuffer.addEventListener("updateend", () => {
+ pump();
+ if (done) finishStream();
+ });
+ } catch (err) {
+ setStatus("SourceBuffer error");
+ setStartButton(false, "Restart");
+ log("SourceBuffer error: " + err.message);
+ return;
+ }
+
+ ws = new WebSocket(config.url);
+ ws.binaryType = "arraybuffer";
+ ws.onopen = () => {
+ setStatus("Streaming...");
+ setStartButton(true, "Streaming...");
+ log("Connected: " + config.url);
+ ws.send(JSON.stringify(config.payload));
+ log("Sent session.start: " + JSON.stringify(config.payload));
+ };
+ ws.onmessage = (event) => {
+ if (typeof event.data === "string") {
+ const msg = JSON.parse(event.data);
+ if (msg.type === "video.start") {
+ log("Video session started: request_id=" + (msg.request_id || "") + " format=" + (msg.format || ""));
+ } else if (msg.type === "session.done") {
+ done = true;
+ setStatus("Done");
+ setStartButton(false, "Restart");
+ log("Session complete: " + JSON.stringify(msg));
+ finishStream();
+ } else if (msg.type === "error") {
+ done = true;
+ setStatus("Error");
+ setStartButton(false, "Restart");
+ log("ERROR: " + (msg.message || JSON.stringify(msg)));
+ finishStream();
+ } else {
+ log("Control message: " + JSON.stringify(msg));
+ }
+ return;
+ }
+
+ const data = event.data;
+ queue.push(data);
+ chunkCount += 1;
+ totalBytes += data.byteLength;
+ log(`[chunk ${String(chunkCount).padStart(3, "0")}] bytes=${data.byteLength} total_bytes=${totalBytes}`);
+ pump();
+ };
+ ws.onerror = () => {
+ done = true;
+ setStatus("Error");
+ setStartButton(false, "Restart");
+ log("ERROR: WebSocket error");
+ finishStream();
+ };
+ ws.onclose = () => {
+ if (!done) {
+ done = true;
+ setStatus("Closed");
+ setStartButton(false, "Restart");
+ log("WebSocket closed before session.done");
+ finishStream();
+ }
+ };
+ }, { once: true });
+
+ return configJson;
+ };
+
+ console.log("video-stream-view.html loaded");
+})();
diff --git a/pyproject.toml b/pyproject.toml
index 82d7af131..196b544e6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,6 +40,7 @@ dev = [
"pytest-asyncio>=0.21.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
+ "websockets>=15.0.1",
"datasets>=2.14.0",
"mypy==1.11.1",
"pre-commit==4.0.1",
diff --git a/tests/diffusion/test_diffusion_engine_cleanup.py b/tests/diffusion/test_diffusion_engine_cleanup.py
index a8985f372..f940c11d6 100644
--- a/tests/diffusion/test_diffusion_engine_cleanup.py
+++ b/tests/diffusion/test_diffusion_engine_cleanup.py
@@ -9,6 +9,7 @@ from unittest.mock import Mock
import pytest
+from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.sched import DiffusionRequestStatus, RequestScheduler
@@ -33,6 +34,7 @@ def _make_engine() -> DiffusionEngine:
engine._rpc_lock = threading.RLock()
engine._cv = threading.Condition(engine._rpc_lock)
engine._out_queue = {}
+ engine._out_queue_streaming = {}
engine._closed = False
engine._shutdown_complete = False
engine.abort_queue = queue.Queue()
@@ -57,6 +59,23 @@ def test_close_completes_pending_async_waiters() -> None:
event_loop.close()
+def test_close_completes_pending_streaming_waiters() -> None:
+ engine = _make_engine()
+ event_loop = asyncio.new_event_loop()
+ try:
+ engine.main_loop = event_loop
+ queue: asyncio.Queue[DiffusionOutput] = asyncio.Queue()
+ engine._out_queue_streaming["pending-stream"] = queue
+
+ engine.close()
+
+ output = queue.get_nowait()
+ assert output.error == "DiffusionEngine is closed."
+ assert output.finished is True
+ finally:
+ event_loop.close()
+
+
def test_handle_finished_requests_ignores_already_drained_waiter() -> None:
class RacingOutQueue(dict):
def __contains__(self, key):
diff --git a/tests/diffusion/test_diffusion_model_runner.py b/tests/diffusion/test_diffusion_model_runner.py
index 960dd072b..95a0d9c27 100644
--- a/tests/diffusion/test_diffusion_model_runner.py
+++ b/tests/diffusion/test_diffusion_model_runner.py
@@ -9,6 +9,7 @@ import torch
import vllm_omni.diffusion.worker.diffusion_model_runner as model_runner_module
from tests.helpers.mark import hardware_test
+from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
pytestmark = [pytest.mark.diffusion]
@@ -31,6 +32,46 @@ class _DummyPipeline:
return self._output
+class _ChunkStepPipeline:
+ device = torch.device("cpu")
+ supports_step_execution = True
+
+ def __init__(self, outputs):
+ self._outputs = outputs
+ self.prepare_calls = 0
+ self.decode_calls = 0
+
+ def prepare_encode(self, state):
+ self.prepare_calls += 1
+ state.prompt_embeds = torch.zeros(1, 1, 1)
+ state.latents = torch.zeros(1, 1)
+ state.timesteps = torch.tensor([1.0, 0.0])
+ state.step_index = 0
+ state.step_in_chunk = 0
+ state.chunk_num_steps = 2
+ state.total_chunks = len(self._outputs)
+ return state
+
+ def denoise_step(self, input_batch, states):
+ del states
+ return torch.ones_like(input_batch.latents)
+
+ def step_scheduler(self, state, noise_pred):
+ state.latents = noise_pred
+ state.step_index += 1
+ state.step_in_chunk += 1
+
+ def post_decode(self, state):
+ output = self._outputs[self.decode_calls]
+ self.decode_calls += 1
+ state.chunk_index += 1
+ state.step_index = 0
+ state.step_in_chunk = 0
+ if not state.request_denoise_completed:
+ state.latents = torch.zeros(1, 1)
+ return output
+
+
def _make_request(skip_cache_refresh: bool = True):
sampling_params = SimpleNamespace(
generator=None,
@@ -52,11 +93,13 @@ def _make_runner(cache_backend, cache_backend_name: str, enable_cache_dit_summar
runner.pipeline = _DummyPipeline(output=SimpleNamespace(output="ok"))
runner.cache_backend = cache_backend
runner.offload_backend = None
+ runner.state_cache = {}
runner.prompt_embed_cache = None
runner.od_config = SimpleNamespace(
cache_backend=cache_backend_name,
enable_cache_dit_summary=enable_cache_dit_summary,
parallel_config=SimpleNamespace(use_hsdp=False),
+ streaming_output=False,
)
runner.kv_transfer_manager = SimpleNamespace(
receive_kv_cache=lambda req, target_device=None: None,
@@ -66,6 +109,49 @@ def _make_runner(cache_backend, cache_backend_name: str, enable_cache_dit_summar
return runner
+@pytest.mark.core_model
+@pytest.mark.cpu
+def test_execute_stepwise_streaming_returns_chunks_at_boundaries(monkeypatch):
+ """Step streaming returns empty step results until a chunk decode boundary."""
+ chunks = [
+ DiffusionOutput(output="chunk-0", finished=False, chunk_index=0, total_chunks=2),
+ DiffusionOutput(output="chunk-1", finished=True, chunk_index=1, total_chunks=2),
+ ]
+ runner = _make_runner(cache_backend=None, cache_backend_name=None)
+ runner.pipeline = _ChunkStepPipeline(chunks)
+ runner.od_config.streaming_output = True
+ runner.od_config.step_execution = True
+ req = _make_request(skip_cache_refresh=True)
+ req.request_id = "req"
+
+ monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
+ monkeypatch.setattr(model_runner_module.current_omni_platform, "reset_peak_memory_stats", lambda: None)
+ monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_reserved", lambda: 0)
+ monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_allocated", lambda: 0)
+ scheduler_output = SimpleNamespace(
+ finished_req_ids=set(),
+ scheduled_new_reqs=[SimpleNamespace(request_id="req", req=req)],
+ scheduled_cached_reqs=SimpleNamespace(request_ids=[]),
+ )
+
+ first = DiffusionModelRunner.execute_stepwise(runner, scheduler_output)
+ assert first.get_request_output("req").result is None
+
+ scheduler_output = SimpleNamespace(
+ finished_req_ids=set(),
+ scheduled_new_reqs=[],
+ scheduled_cached_reqs=SimpleNamespace(request_ids=["req"]),
+ )
+ second = DiffusionModelRunner.execute_stepwise(runner, scheduler_output)
+ assert second.get_request_output("req").result == chunks[0]
+ assert second.get_request_output("req").finished is False
+
+ DiffusionModelRunner.execute_stepwise(runner, scheduler_output)
+ fourth = DiffusionModelRunner.execute_stepwise(runner, scheduler_output)
+ assert fourth.get_request_output("req").result == chunks[1]
+ assert fourth.get_request_output("req").finished is True
+
+
@pytest.mark.core_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_execute_model_skips_cache_summary_without_active_cache_backend(monkeypatch):
@@ -76,6 +162,9 @@ def test_execute_model_skips_cache_summary_without_active_cache_backend(monkeypa
cache_summary_calls = []
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
+ monkeypatch.setattr(model_runner_module.current_omni_platform, "reset_peak_memory_stats", lambda: None)
+ monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_reserved", lambda: 0)
+ monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_allocated", lambda: 0)
monkeypatch.setattr(
model_runner_module,
"cache_summary",
@@ -101,6 +190,9 @@ def test_execute_model_emits_cache_summary_with_active_cache_dit_backend(monkeyp
cache_summary_calls = []
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
+ monkeypatch.setattr(model_runner_module.current_omni_platform, "reset_peak_memory_stats", lambda: None)
+ monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_reserved", lambda: 0)
+ monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_allocated", lambda: 0)
monkeypatch.setattr(
model_runner_module,
"cache_summary",
@@ -157,6 +249,7 @@ def test_load_model_clears_cache_backend_for_unsupported_pipeline(monkeypatch):
cache_config={},
model_class_name="NextStep11Pipeline",
enforce_eager=True,
+ streaming_output=False,
)
monkeypatch.setattr(model_runner_module, "LoadConfig", lambda: object())
diff --git a/tests/diffusion/test_diffusion_scheduler.py b/tests/diffusion/test_diffusion_scheduler.py
index 503ea0842..280b56420 100644
--- a/tests/diffusion/test_diffusion_scheduler.py
+++ b/tests/diffusion/test_diffusion_scheduler.py
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+import asyncio
import queue
import threading
from types import SimpleNamespace
@@ -238,6 +239,35 @@ class TestRequestScheduler:
assert finished == {req_id}
assert self.scheduler.get_request_state(req_id).status == DiffusionRequestStatus.FINISHED_COMPLETED
+ def test_streaming_output_keeps_request_running_until_final_chunk(self) -> None:
+ scheduler = StepScheduler()
+ scheduler.initialize(SimpleNamespace())
+ req_id = scheduler.add_request(_make_request("stream"))
+
+ sched_output = scheduler.schedule()
+ chunk = RunnerOutput(
+ request_id=req_id,
+ step_index=1,
+ finished=False,
+ result=DiffusionOutput(output="chunk-0", finished=False, chunk_index=0, total_chunks=2),
+ )
+ finished = scheduler.update_from_output(sched_output, chunk)
+
+ assert finished == set()
+ assert scheduler.get_request_state(req_id).status == DiffusionRequestStatus.RUNNING
+ assert scheduler.has_requests() is True
+
+ final_chunk = RunnerOutput(
+ request_id=req_id,
+ step_index=2,
+ finished=True,
+ result=DiffusionOutput(output="chunk-1", finished=True, chunk_index=1, total_chunks=2),
+ )
+ finished = scheduler.update_from_output(sched_output, final_chunk)
+
+ assert finished == {req_id}
+ assert scheduler.get_request_state(req_id).status == DiffusionRequestStatus.FINISHED_COMPLETED
+
def test_fifo_single_request_scheduling(self) -> None:
req_id_a = self.scheduler.add_request(_make_request("a"))
req_id_b = self.scheduler.add_request(_make_request("b"))
@@ -369,6 +399,7 @@ class TestRequestScheduler:
class TestDiffusionEngine:
def test_add_req_and_wait_for_response_single_path(self, mocker: MockerFixture) -> None:
engine = DiffusionEngine.__new__(DiffusionEngine)
+ engine.od_config = SimpleNamespace(streaming_output=False)
engine.scheduler = RequestScheduler()
engine.scheduler.initialize(SimpleNamespace())
engine._rpc_lock = threading.RLock()
@@ -391,6 +422,7 @@ class TestDiffusionEngine:
scheduler = _StubScheduler(request, runner_output)
engine = DiffusionEngine.__new__(DiffusionEngine)
+ engine.od_config = SimpleNamespace(streaming_output=False)
engine.scheduler = scheduler
engine._rpc_lock = threading.RLock()
engine._cv = threading.Condition(engine._rpc_lock)
@@ -410,7 +442,7 @@ class TestDiffusionEngine:
) -> None:
request = _make_request("init")
scheduler = _StubScheduler(request, DiffusionOutput(output=None))
- od_config = SimpleNamespace(model_class_name="mock_model")
+ od_config = SimpleNamespace(model_class_name="mock_model", streaming_output=False)
fake_executor_cls = mocker.Mock(return_value=mocker.Mock())
monkeypatch.setattr(
@@ -472,7 +504,7 @@ class TestDiffusionEngine:
def test_finalize_finished_request_returns_aborted_output(self) -> None:
engine = DiffusionEngine.__new__(DiffusionEngine)
- engine.scheduler = RequestScheduler()
+ engine.scheduler = StepScheduler()
engine.scheduler.initialize(SimpleNamespace())
req_id = engine.scheduler.add_request(_make_request("req-finalize"))
@@ -483,12 +515,77 @@ class TestDiffusionEngine:
assert output.aborted is True
assert output.abort_message == "Request req-finalize aborted."
+ @pytest.mark.asyncio
+ async def test_streaming_runner_output_notifies_each_chunk(self) -> None:
+ engine = DiffusionEngine.__new__(DiffusionEngine)
+ engine.scheduler = StepScheduler()
+ engine.scheduler.initialize(SimpleNamespace())
+ engine._rpc_lock = threading.RLock()
+ engine._cv = threading.Condition(engine._rpc_lock)
+ engine._out_queue_streaming = {}
+ engine.main_loop = asyncio.get_running_loop()
+
+ req_id = engine.scheduler.add_request(_make_request("stream-engine"))
+ queue: asyncio.Queue[DiffusionOutput] = asyncio.Queue()
+ engine._out_queue_streaming[req_id] = queue
+ sched_output = engine.scheduler.schedule()
+
+ chunk = RunnerOutput(
+ request_id=req_id,
+ step_index=1,
+ finished=False,
+ result=DiffusionOutput(output="chunk-0", finished=False, chunk_index=0, total_chunks=2),
+ )
+ finished_req_ids = engine.scheduler.update_from_output(sched_output, chunk)
+ engine._handle_step_streaming_runner_output(finished_req_ids, sched_output.scheduled_request_ids, chunk)
+
+ notified_chunk = await asyncio.wait_for(queue.get(), timeout=1)
+ assert notified_chunk.output == "chunk-0"
+ assert notified_chunk.finished is False
+ assert engine.scheduler.get_request_state(req_id).status == DiffusionRequestStatus.RUNNING
+
+ final_chunk = RunnerOutput(
+ request_id=req_id,
+ step_index=2,
+ finished=True,
+ result=DiffusionOutput(output="chunk-1", finished=True, chunk_index=1, total_chunks=2),
+ )
+ finished_req_ids = engine.scheduler.update_from_output(sched_output, final_chunk)
+ engine._handle_step_streaming_runner_output(finished_req_ids, sched_output.scheduled_request_ids, final_chunk)
+
+ notified_final = await asyncio.wait_for(queue.get(), timeout=1)
+ assert notified_final.output == "chunk-1"
+ assert notified_final.finished is True
+ assert engine.scheduler.get_request_state(req_id) is None
+
+ @pytest.mark.asyncio
+ async def test_finished_streaming_request_without_runner_output_notifies_waiter(self) -> None:
+ engine = DiffusionEngine.__new__(DiffusionEngine)
+ engine.scheduler = RequestScheduler()
+ engine.scheduler.initialize(SimpleNamespace())
+ engine._rpc_lock = threading.RLock()
+ engine._cv = threading.Condition(engine._rpc_lock)
+ engine._out_queue_streaming = {}
+ engine.main_loop = asyncio.get_running_loop()
+
+ req_id = engine.scheduler.add_request(_make_request("stream-abort"))
+ queue: asyncio.Queue[DiffusionOutput] = asyncio.Queue()
+ engine._out_queue_streaming[req_id] = queue
+ engine.scheduler.finish_requests(req_id, DiffusionRequestStatus.FINISHED_ABORTED)
+
+ engine._handle_empty_streaming_requests({req_id})
+
+ output = await asyncio.wait_for(queue.get(), timeout=1)
+ assert output.aborted is True
+ assert output.finished is True
+ assert engine.scheduler.get_request_state(req_id) is None
+
def test_initializes_step_scheduler_when_step_execution_enabled(
self,
monkeypatch: pytest.MonkeyPatch,
mocker: MockerFixture,
) -> None:
- od_config = SimpleNamespace(model_class_name="mock_model")
+ od_config = SimpleNamespace(model_class_name="mock_model", streaming_output=False)
od_config.step_execution = True
fake_executor = mocker.Mock()
fake_executor_cls = mocker.Mock(return_value=fake_executor)
diff --git a/tests/diffusion/test_diffusion_step_pipeline.py b/tests/diffusion/test_diffusion_step_pipeline.py
index a21813d28..6d26d073d 100644
--- a/tests/diffusion/test_diffusion_step_pipeline.py
+++ b/tests/diffusion/test_diffusion_step_pipeline.py
@@ -249,6 +249,7 @@ def _make_runner():
runner.od_config = SimpleNamespace(
cache_backend=None,
parallel_config=SimpleNamespace(use_hsdp=False),
+ streaming_output=False,
)
runner.device = torch.device("cpu")
runner.pipeline = _StepPipeline()
@@ -267,6 +268,7 @@ def _make_distributed_runner(mode: str, device: torch.device):
runner.od_config = SimpleNamespace(
cache_backend=None,
parallel_config=SimpleNamespace(use_hsdp=False),
+ streaming_output=False,
)
runner.device = device
runner.pipeline = _DistributedStepPipeline(mode=mode, device=device)
@@ -327,7 +329,7 @@ def _make_cached_scheduler_output(request_id="req-1", step_id=1, finished_req_id
def _make_engine(scheduler, execute_fn=None) -> DiffusionEngine:
engine = object.__new__(DiffusionEngine)
- engine.od_config = SimpleNamespace(model_class_name="QwenImagePipeline")
+ engine.od_config = SimpleNamespace(model_class_name="QwenImagePipeline", streaming_output=False)
engine.pre_process_func = None
engine.post_process_func = None
engine.scheduler = scheduler
@@ -563,6 +565,7 @@ class TestRunner:
step_execution=True,
model_class_name="RequestOnlyPipeline",
parallel_config=SimpleNamespace(use_hsdp=False),
+ streaming_output=False,
)
runner.device = torch.device("cpu")
runner.pipeline = None
@@ -710,6 +713,7 @@ class TestExecutor:
def test_execute_step_passes_through_runner_output(self, mocker: MockerFixture):
executor = object.__new__(MultiprocDiffusionExecutor)
+ executor.od_config = SimpleNamespace(streaming_output=False)
executor._ensure_open = lambda: None
expected = RunnerOutput(request_id="req-step", step_index=1, finished=False, result=None)
executor.collective_rpc = mocker.Mock(return_value=expected)
diff --git a/tests/diffusion/test_diffusion_streaming_output.py b/tests/diffusion/test_diffusion_streaming_output.py
new file mode 100644
index 000000000..50ed772c1
--- /dev/null
+++ b/tests/diffusion/test_diffusion_streaming_output.py
@@ -0,0 +1,525 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Integration tests for diffusion pipeline streaming output mode.
+
+Covers:
+- Mock pipeline -> StageDiffusionClient (ZMQ subprocess path)
+- Mock pipeline -> InlineStageDiffusionClient -> Orchestrator -> AsyncOmni
+- Mock pipeline -> InlineStageDiffusionClient -> Orchestrator -> AsyncOmni -> `/v1/realtime/video`
+"""
+
+import asyncio
+import contextlib
+import queue
+import time
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import numpy as np
+import pytest
+import torch
+import zmq
+from fastapi import FastAPI, WebSocket
+from pytest_mock import MockerFixture
+from starlette.testclient import TestClient
+from vllm.utils.network_utils import get_open_zmq_ipc_path
+
+import vllm_omni.diffusion.worker.diffusion_model_runner as model_runner_module
+from tests.engine.test_orchestrator import OrchestratorFixture, _build_harness, _wait_for
+from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
+from vllm_omni.diffusion.inline_stage_diffusion_client import InlineStageDiffusionClient
+from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
+from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc
+from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
+from vllm_omni.distributed.omni_connectors.utils.serialization import (
+ OmniMsgpackDecoder,
+ OmniMsgpackEncoder,
+)
+from vllm_omni.engine.async_omni_engine import StageRuntimeInfo
+from vllm_omni.engine.messages import ShutdownRequestMessage, StageSubmissionMessage
+from vllm_omni.engine.stage_init_utils import StageMetadata
+from vllm_omni.entrypoints.async_omni import AsyncEventResolver, AsyncOmni
+from vllm_omni.entrypoints.openai.serving_video_output_stream import OmniStreamingVideoOutputHandler
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+from vllm_omni.outputs import OmniRequestOutput
+
+pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
+
+
+class _StepStreamingPipeline:
+ supports_step_execution = True
+
+ def __init__(self, outputs: list[DiffusionOutput]) -> None:
+ self.outputs = outputs
+ self.requests: list[Any] = []
+
+ def step_outputs(self, request):
+ self.requests.append(request)
+ return list(self.outputs)
+
+
+class _FailingStreamingPipeline:
+ supports_step_execution = True
+
+ def __init__(self) -> None:
+ self.requests: list[Any] = []
+
+ def step_outputs(self, request):
+ self.requests.append(request)
+ raise RuntimeError("stream failed midway")
+
+
+class _PipelineBackedEngine:
+ """DiffusionEngine stand-in that exposes step-streaming chunks from a mock pipeline."""
+
+ def __init__(self, pipeline: _StepStreamingPipeline | _FailingStreamingPipeline) -> None:
+ self.pipeline = pipeline
+ self.executor = SimpleNamespace(
+ register_failure_callback=MagicMock(),
+ check_health=MagicMock(),
+ )
+
+ async def step_streaming(self, request):
+ try:
+ outputs = self.pipeline.step_outputs(request)
+ except Exception as exc:
+ outputs = [
+ DiffusionOutput(custom_output={"chunk": 0}, finished=False),
+ DiffusionOutput(error=str(exc), finished=True),
+ ]
+ for output in outputs:
+ if output.error is not None:
+ yield [OmniRequestOutput.from_error(request_id=request.request_id, error_message=output.error)]
+ continue
+ custom_output = output.custom_output or {}
+ yield [
+ OmniRequestOutput.from_diffusion(
+ request_id=request.request_id,
+ images=list(custom_output.get("images") or []),
+ custom_output=custom_output,
+ finished=output.finished,
+ )
+ ]
+
+ def abort(self, request_id: str) -> None:
+ del request_id
+
+
+class TestPipelineStreamingOutputToStageDiffusionClient:
+ """Streaming pipeline output over ZMQ into ``StageDiffusionClient``."""
+
+ @pytest.mark.asyncio
+ async def test_streaming_output_reaches_stage_client_from_mock_pipeline(self) -> None:
+ """Mock pipeline chunks over ZMQ reach StageDiffusionClient with correct finished flags."""
+ pipeline = _StepStreamingPipeline(
+ [
+ DiffusionOutput(custom_output={"chunk": 0}, finished=False),
+ DiffusionOutput(custom_output={"chunk": 1}, finished=True),
+ ]
+ )
+
+ outputs = await self._run_streaming_client_proc(pipeline, request_id="req-stream")
+
+ assert [output.request_id for output in outputs] == ["req-stream", "req-stream"]
+ assert [output.finished for output in outputs] == [False, True]
+ assert [output.custom_output["chunk"] for output in outputs] == [0, 1]
+ assert pipeline.requests[0].request_id == "req-stream"
+
+ @pytest.mark.asyncio
+ async def test_streaming_midway_error_reaches_stage_client_after_prior_chunk(self) -> None:
+ """A pipeline error after the first chunk still delivers that chunk then a terminal error output."""
+ pipeline = _FailingStreamingPipeline()
+
+ outputs = await self._run_streaming_client_proc(pipeline, request_id="req-error")
+
+ assert len(outputs) == 2
+ assert outputs[0].request_id == "req-error"
+ assert outputs[0].finished is False
+ assert outputs[0].custom_output["chunk"] == 0
+ assert outputs[1].request_id == "req-error"
+ assert outputs[1].finished is True
+ assert outputs[1].error is not None
+ assert "stream failed midway" in outputs[1].error
+
+ @classmethod
+ async def _run_streaming_client_proc(
+ cls,
+ pipeline: _StepStreamingPipeline | _FailingStreamingPipeline,
+ *,
+ request_id: str,
+ ) -> list[OmniRequestOutput]:
+ request_address = get_open_zmq_ipc_path()
+ response_address = get_open_zmq_ipc_path()
+ proc = object.__new__(StageDiffusionProc)
+ proc._od_config = SimpleNamespace(streaming_output=True)
+ proc._engine = _PipelineBackedEngine(pipeline)
+ proc._closed = False
+ client = cls._make_client(request_address, response_address)
+ proc_task = asyncio.create_task(proc.run_loop(request_address, response_address))
+ try:
+ await asyncio.sleep(0.05)
+ await client.add_request_async(
+ request_id,
+ "prompt",
+ OmniDiffusionSamplingParams(),
+ )
+ deadline = asyncio.get_running_loop().time() + 2.0
+ outputs: list[OmniRequestOutput] = []
+ while (not outputs or not outputs[-1].finished) and asyncio.get_running_loop().time() < deadline:
+ output = client.get_diffusion_output_nowait()
+ if output is not None:
+ outputs.append(output)
+ else:
+ await asyncio.sleep(0.01)
+ return outputs
+ finally:
+ try:
+ client._request_socket.send(client._encoder.encode({"type": "shutdown"}))
+ except Exception:
+ pass
+ try:
+ await asyncio.wait_for(proc_task, timeout=2)
+ finally:
+ cls._close_client(client)
+
+ @staticmethod
+ def _make_client(request_address: str, response_address: str) -> StageDiffusionClient:
+ client = object.__new__(StageDiffusionClient)
+ client.stage_id = 0
+ client.replica_id = 0
+ client.final_output = True
+ client.final_output_type = "image"
+ client.default_sampling_params = {}
+ client.requires_multimodal_data = False
+ client.custom_process_input_func = None
+ client.engine_input_source = []
+ client._proc = None
+ client._proc_manager = None
+ client._owns_process = False
+ client._zmq_ctx = zmq.Context()
+ client._request_socket = client._zmq_ctx.socket(zmq.PUSH)
+ client._request_socket.bind(request_address)
+ client._response_socket = client._zmq_ctx.socket(zmq.PULL)
+ client._response_socket.bind(response_address)
+ client._encoder = OmniMsgpackEncoder()
+ client._decoder = OmniMsgpackDecoder()
+ client._output_queue: asyncio.Queue[OmniRequestOutput] = asyncio.Queue()
+ client._rpc_results = {}
+ client._pending_rpcs = set()
+ client._tasks = {}
+ client._shutting_down = False
+ client._engine_dead = False
+ return client
+
+ @staticmethod
+ def _close_client(client: StageDiffusionClient) -> None:
+ client._request_socket.close(linger=0)
+ client._response_socket.close(linger=0)
+ client._zmq_ctx.term()
+
+
+class TestPipelineStreamingOutputToEntrypoint:
+ """Streaming pipeline output through inline stage, orchestrator, and entrypoints."""
+
+ @pytest.mark.asyncio
+ async def test_streaming_output_reaches_async_omni_from_mock_pipeline(self) -> None:
+ """Mock pipeline streaming chunks reach AsyncOmni.generate() via inline stage and orchestrator."""
+ pipeline = _StepStreamingPipeline(
+ [
+ DiffusionOutput(custom_output={"chunk": 0}, finished=False),
+ DiffusionOutput(custom_output={"chunk": 1}, finished=True),
+ ]
+ )
+ inline_client = self._make_inline_pipeline_client(pipeline)
+ fixture = _build_harness([inline_client])
+ omni = self._make_async_omni(self._OrchestratorBridgeEngine(fixture))
+
+ try:
+ outputs: list[OmniRequestOutput] = []
+ async for output in omni.generate(
+ prompt={"prompt": "a cat"},
+ request_id="req-omni",
+ sampling_params_list=[OmniDiffusionSamplingParams()],
+ output_modalities=["image"],
+ ):
+ outputs.append(output)
+
+ await _wait_for(lambda: len(pipeline.requests) == 1)
+ assert pipeline.requests[0].request_id.startswith("req-omni-")
+ assert [output.custom_output["chunk"] for output in outputs] == [0, 1]
+ assert [getattr(output.request_output, "finished", output.finished) for output in outputs] == [False, True]
+ finally:
+ await self._shutdown_pipeline_omni_harness(omni, fixture, inline_client)
+
+ @pytest.mark.asyncio
+ async def test_streaming_output_reaches_api_websocket_from_mock_pipeline(self, mocker: MockerFixture) -> None:
+ """Mock pipeline streaming chunks reach the video WebSocket handler through AsyncOmni.generate()."""
+ frames = [np.full((4, 4, 3), fill_value=i, dtype=np.uint8) for i in range(2)]
+ pipeline = _StepStreamingPipeline(
+ [
+ DiffusionOutput(custom_output={"chunk": 0, "images": [frames]}, finished=False),
+ DiffusionOutput(custom_output={"chunk": 1, "images": [frames]}, finished=True),
+ ]
+ )
+ inline_client = self._make_inline_pipeline_client(pipeline)
+ fixture = _build_harness([inline_client])
+ bridge_engine = self._OrchestratorBridgeEngine(fixture)
+ omni = self._make_async_omni(bridge_engine)
+
+ encoded = [b"pipeline-fmp4-0", b"pipeline-fmp4-1"]
+
+ class FakeStreamingVideoEncoder:
+ encode_calls: list[int] = []
+
+ def encode(self, video):
+ del video
+ idx = len(self.encode_calls)
+ self.encode_calls.append(idx)
+ return encoded[idx]
+
+ def close(self):
+ return b""
+
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.create_streaming_video_encoder",
+ lambda *, output_format, fps, video_codec_options=None: FakeStreamingVideoEncoder(),
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.get_stage_type",
+ return_value="diffusion",
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.build_stage_sampling_params_list",
+ return_value=[OmniDiffusionSamplingParams()],
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.get_default_sampling_params_list",
+ return_value=[OmniDiffusionSamplingParams()],
+ )
+
+ handler = OmniStreamingVideoOutputHandler(
+ engine_client=omni,
+ model_name="test-model",
+ stage_configs=bridge_engine.stage_configs,
+ stall_timeout=5.0,
+ start_timeout=5.0,
+ )
+ app = FastAPI()
+
+ @app.websocket("/v1/realtime/video")
+ async def ws_endpoint(websocket: WebSocket):
+ await handler.handle_session(websocket)
+
+ try:
+ with TestClient(app) as client:
+ with client.websocket_connect("/v1/realtime/video") as ws:
+ ws.send_json({"type": "session.start", "prompt": "integration test"})
+ assert ws.receive_json()["type"] == "video.start"
+ assert ws.receive_bytes() == b"pipeline-fmp4-0"
+ assert ws.receive_bytes() == b"pipeline-fmp4-1"
+ done = ws.receive_json()
+ assert done["type"] == "session.done"
+ assert done["chunks"] == 2
+
+ await _wait_for(lambda: len(pipeline.requests) == 1)
+ assert pipeline.requests[0].request_id
+ finally:
+ await self._shutdown_pipeline_omni_harness(omni, fixture, inline_client)
+
+ class _OrchestratorBridgeEngine:
+ """Minimal AsyncOmni engine facade backed by a live Orchestrator harness."""
+
+ def __init__(self, fixture: OrchestratorFixture) -> None:
+ self._fixture = fixture
+ self.stage_metadata = [
+ StageRuntimeInfo(
+ stage_type="diffusion",
+ final_output=True,
+ final_output_type="image",
+ )
+ ]
+ self.stage_configs: list[Any] = [SimpleNamespace(stage_type="diffusion")]
+ self.default_sampling_params_list = [OmniDiffusionSamplingParams()]
+ self.num_stages = 1
+ self.supported_tasks = ("generate",)
+ self._alive = True
+
+ async def add_request_async(
+ self,
+ *,
+ request_id: str,
+ prompt: Any,
+ sampling_params_list: list[Any],
+ final_stage_id: int,
+ **kwargs: Any,
+ ) -> None:
+ self._fixture.request_sync_q.put_nowait(
+ StageSubmissionMessage(
+ type="add_request",
+ request_id=request_id,
+ prompt=prompt,
+ original_prompt=prompt,
+ output_prompt_text=None,
+ sampling_params_list=sampling_params_list,
+ final_stage_id=final_stage_id,
+ final_output_stage_ids=kwargs.get("final_output_stage_ids"),
+ preprocess_ms=0.0,
+ request_timestamp=kwargs.get("arrival_time", time.time()),
+ enqueue_ts=time.perf_counter(),
+ )
+ )
+
+ async def try_get_output_async(self) -> Any | None:
+ try:
+ return self._fixture.output_sync_q.get_nowait()
+ except queue.Empty:
+ return None
+
+ def get_stage_metadata(self, stage_id: int) -> StageRuntimeInfo:
+ return self.stage_metadata[stage_id]
+
+ def is_alive(self) -> bool:
+ return self._fixture.thread.is_alive()
+
+ async def abort_async(self, request_ids: list[str]) -> None:
+ del request_ids
+
+ @classmethod
+ def _make_inline_pipeline_client(cls, pipeline: _StepStreamingPipeline) -> InlineStageDiffusionClient:
+ """Build an inline diffusion stage client whose engine runs the given mock pipeline."""
+ metadata = StageMetadata(
+ stage_id=0,
+ stage_type="diffusion",
+ engine_output_type="image",
+ is_comprehension=False,
+ requires_multimodal_data=False,
+ engine_input_source=[],
+ final_output=True,
+ final_output_type="image",
+ default_sampling_params=OmniDiffusionSamplingParams(),
+ custom_process_input_func=None,
+ model_stage=None,
+ runtime_cfg=None,
+ )
+ pipeline_engine = _PipelineBackedEngine(pipeline)
+ with patch.object(InlineStageDiffusionClient, "_enrich_config"):
+ with patch(
+ "vllm_omni.diffusion.inline_stage_diffusion_client.DiffusionEngine.make_engine",
+ return_value=pipeline_engine,
+ ):
+ od_config = MagicMock(spec=OmniDiffusionConfig)
+ od_config.streaming_output = True
+ return InlineStageDiffusionClient(
+ model="test_model",
+ od_config=od_config,
+ metadata=metadata,
+ batch_size=1,
+ )
+
+ @staticmethod
+ def _make_async_omni(engine: _OrchestratorBridgeEngine) -> AsyncOmni:
+ omni = object.__new__(AsyncOmni)
+ omni.engine = engine
+ omni.log_stats = False
+ omni._pause_cond = asyncio.Condition()
+ omni._paused = False
+ omni.request_states = {}
+ omni.final_output_task = None
+ omni.event_resolver = AsyncEventResolver()
+ omni._enable_ar_profiler = False
+ omni._is_sleeping = False
+ omni.prom_metrics = MagicMock()
+ omni.mod_metrics = MagicMock()
+ omni.resolve_sampling_params_list = lambda params, allow_delta_coercion: params
+ omni._compute_final_stage_id = lambda output_modalities: 0
+ omni._compute_final_output_stage_ids = lambda output_modalities: [0]
+ omni.default_sampling_params_list = engine.default_sampling_params_list
+ omni._log_summary_and_cleanup = lambda request_id: omni.request_states.pop(request_id, None)
+ return omni
+
+ @staticmethod
+ async def _shutdown_pipeline_omni_harness(
+ omni: AsyncOmni,
+ fixture: OrchestratorFixture,
+ inline_client: InlineStageDiffusionClient,
+ ) -> None:
+ if omni.final_output_task is not None:
+ omni.final_output_task.cancel()
+ await asyncio.gather(omni.final_output_task, return_exceptions=True)
+ inline_client.shutdown()
+ fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
+ await asyncio.to_thread(fixture.thread.join, 5)
+
+
+def _make_vllm_config():
+ @contextlib.contextmanager
+ def set_priority(*args, **kwargs):
+ del args, kwargs
+ yield
+
+ return SimpleNamespace(
+ kernel_config=SimpleNamespace(ir_op_priority=SimpleNamespace(set_priority=set_priority)),
+ compilation_config=SimpleNamespace(ir_enable_torch_wrap=True),
+ )
+
+
+class TestSupportedPipelines:
+ """Streaming-output protocol checks for supported pipelines."""
+
+ def test_helios_supports_step_execution_for_streaming_output(self) -> None:
+ from vllm_omni.diffusion.models.helios.pipeline_helios import HeliosPipeline
+ from vllm_omni.diffusion.models.interface import SupportsStepExecution, supports_step_execution
+
+ # Avoid loading model weights; protocol membership depends on the class contract.
+ pipeline = object.__new__(HeliosPipeline)
+
+ assert pipeline.supports_step_execution is True
+ assert supports_step_execution(pipeline) is True
+ assert isinstance(pipeline, SupportsStepExecution) is True
+
+ def test_load_model_rejects_streaming_output_without_step_execution(self, monkeypatch) -> None:
+ class _NoStepPipeline:
+ def forward(self): ...
+
+ class _FakeDiffusersPipelineLoader:
+ def __init__(self, *args, **kwargs): ...
+
+ def load_model(self, **kwargs):
+ return _NoStepPipeline()
+
+ class _FakeDeviceMemoryProfiler:
+ consumed_memory = 0
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ runner = object.__new__(DiffusionModelRunner)
+ runner.vllm_config = _make_vllm_config()
+ runner.od_config = SimpleNamespace(
+ enable_cpu_offload=False,
+ enable_layerwise_offload=False,
+ enforce_eager=True,
+ cache_backend=None,
+ cache_config=None,
+ streaming_output=True,
+ step_execution=False,
+ model_class_name="NoStepPipeline",
+ parallel_config=SimpleNamespace(use_hsdp=False),
+ )
+ runner.device = torch.device("cpu")
+ runner.pipeline = None
+ runner.cache_backend = None
+ runner.offload_backend = None
+ runner.kv_transfer_manager = SimpleNamespace()
+
+ monkeypatch.setattr(model_runner_module, "DiffusersPipelineLoader", _FakeDiffusersPipelineLoader)
+ monkeypatch.setattr(model_runner_module, "DeviceMemoryProfiler", _FakeDeviceMemoryProfiler)
+ monkeypatch.setattr(model_runner_module, "get_offload_backend", lambda *args, **kwargs: None)
+ monkeypatch.setattr(model_runner_module, "get_cache_backend", lambda *args, **kwargs: None)
+
+ with pytest.raises(ValueError, match="NoStepPipeline"):
+ DiffusionModelRunner.load_model(runner)
diff --git a/tests/diffusion/test_inline_stage_diffusion_client.py b/tests/diffusion/test_inline_stage_diffusion_client.py
index 9c10d46b4..b94f86db0 100644
--- a/tests/diffusion/test_inline_stage_diffusion_client.py
+++ b/tests/diffusion/test_inline_stage_diffusion_client.py
@@ -46,6 +46,7 @@ def client(mock_engine):
)
with patch.object(InlineStageDiffusionClient, "_enrich_config"):
od_config = MagicMock(spec=OmniDiffusionConfig)
+ od_config.streaming_output = False
c = InlineStageDiffusionClient(model="test_model", od_config=od_config, metadata=metadata, batch_size=1)
yield c
c.shutdown()
@@ -72,6 +73,35 @@ async def test_inline_dispatch_request_success(client, mock_engine):
mock_engine.step.assert_called_once()
+@pytest.mark.asyncio
+async def test_inline_dispatch_request_streaming_success(client, mock_engine):
+ """Inline StageDiffusionClient correctly receives streaming chunk output from Diffusion Engine"""
+ chunks = [
+ OmniRequestOutput.from_diffusion(request_id="req-stream", images=[], finished=False),
+ OmniRequestOutput.from_diffusion(request_id="req-stream", images=[], finished=True),
+ ]
+
+ async def _step_streaming(_request):
+ for chunk in chunks:
+ yield [chunk]
+
+ client.od_config.streaming_output = True
+ mock_engine.step_streaming = _step_streaming
+
+ await client.add_request_async("req-stream", "A test prompt", OmniDiffusionSamplingParams())
+
+ outputs = []
+ for _ in range(20):
+ output = client.get_diffusion_output_nowait()
+ if output is not None:
+ outputs.append(output)
+ if output.finished:
+ break
+ await asyncio.sleep(0.01)
+
+ assert outputs == chunks
+
+
@pytest.mark.asyncio
async def test_inline_dispatch_request_error(client, mock_engine):
# Setup mock engine step to raise an exception
diff --git a/tests/diffusion/test_multiproc_engine_concurrency.py b/tests/diffusion/test_multiproc_engine_concurrency.py
index 1b015c02a..5bfb8d738 100644
--- a/tests/diffusion/test_multiproc_engine_concurrency.py
+++ b/tests/diffusion/test_multiproc_engine_concurrency.py
@@ -21,6 +21,7 @@ from vllm_omni.diffusion.ipc import DIFFUSION_RPC_RESULT_ENVELOPE
from vllm_omni.diffusion.sched import RequestScheduler
from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc
from vllm_omni.diffusion.worker.diffusion_worker import WorkerProc
+from vllm_omni.diffusion.worker.utils import RunnerOutput
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -49,7 +50,7 @@ def _make_executor(num_gpus: int = 1):
Returns ``(executor, request_queue, result_queue)``.
"""
- od_cfg = SimpleNamespace(num_gpus=num_gpus)
+ od_cfg = SimpleNamespace(num_gpus=num_gpus, streaming_output=False)
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(MultiprocDiffusionExecutor, "_init_executor", lambda self: None)
executor = MultiprocDiffusionExecutor(od_cfg)
@@ -75,6 +76,7 @@ def _make_engine(num_gpus: int = 1):
"""Create a lightweight ``DiffusionEngine`` wired to mocked executor."""
executor, req_q, res_q = _make_executor(num_gpus)
engine = DiffusionEngine.__new__(DiffusionEngine)
+ engine.od_config = SimpleNamespace(streaming_output=False)
sched = RequestScheduler()
sched.initialize(SimpleNamespace())
engine.scheduler = sched
@@ -581,6 +583,58 @@ class TestMultiprocExecutorRaisesEngineDeadError:
)
+class TestMultiprocExecutorStepStreamingOutput:
+ """Streaming output uses step execution and one worker reply per step."""
+
+ def test_execute_step_allows_streaming_output_mode(self):
+ executor, req_q, res_q = _make_executor()
+ executor.od_config = SimpleNamespace(streaming_output=True) # pyright: ignore[reportAttributeAccessIssue]
+ runner_outputs = [
+ RunnerOutput(
+ request_id="sched-stream",
+ step_index=1,
+ finished=False,
+ result=DiffusionOutput(output={"chunk": 0}, finished=False, chunk_index=0, total_chunks=2),
+ ),
+ RunnerOutput(
+ request_id="sched-stream",
+ step_index=2,
+ finished=True,
+ result=DiffusionOutput(output={"chunk": 1}, finished=True, chunk_index=1, total_chunks=2),
+ ),
+ ]
+ scheduler_output = SimpleNamespace(
+ scheduled_request_ids=["sched-stream"],
+ )
+
+ def _worker():
+ for runner_output in runner_outputs:
+ req_q.get(timeout=10)
+ res_q.put(runner_output)
+
+ thread = threading.Thread(target=_worker, daemon=True)
+ thread.start()
+
+ first: RunnerOutput = MultiprocDiffusionExecutor.execute_step( # pyright: ignore[reportAssignmentType]
+ executor,
+ scheduler_output, # pyright: ignore[reportArgumentType]
+ )
+ second: RunnerOutput = MultiprocDiffusionExecutor.execute_step( # pyright: ignore[reportAssignmentType]
+ executor,
+ scheduler_output, # pyright: ignore[reportArgumentType]
+ )
+
+ assert first is runner_outputs[0]
+ assert first.result is not None
+ assert first.result.output == {"chunk": 0}
+ assert first.finished is False
+ assert second is runner_outputs[1]
+ assert second.result is not None
+ assert second.result.output == {"chunk": 1}
+ assert second.finished is True
+ thread.join(timeout=2)
+
+
class TestDiffusionEngineDeadErrorPassthrough:
"""``DiffusionEngine.add_req_and_wait_for_response`` re-raises
``EngineDeadError`` from executor and wraps other errors."""
diff --git a/tests/diffusion/test_stage_diffusion_proc.py b/tests/diffusion/test_stage_diffusion_proc.py
index dcefafb8d..3038d4e90 100644
--- a/tests/diffusion/test_stage_diffusion_proc.py
+++ b/tests/diffusion/test_stage_diffusion_proc.py
@@ -11,6 +11,7 @@ import pytest
from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
@@ -54,6 +55,7 @@ def test_process_batch_request_preserves_parent_request_id_and_kv_sender_info():
proc = object.__new__(StageDiffusionProc)
proc._engine = SimpleNamespace(step=step)
+ proc._od_config = SimpleNamespace(streaming_output=False)
proc._executor = ThreadPoolExecutor(max_workers=1)
try:
@@ -102,6 +104,40 @@ class MockDiffusionEngine:
return [MockOmniRequestOutput(request_id=request.request_id)]
+@pytest.mark.asyncio
+async def test_proc_streaming_request_yields_each_engine_chunk():
+ """Ensure that the streaming output chunks from DiffusionEngine reaches StageDiffusionProc"""
+ captured = {}
+ chunks = [
+ OmniRequestOutput.from_diffusion(request_id="", images=[], finished=False),
+ OmniRequestOutput.from_diffusion(request_id="", images=[], finished=True),
+ ]
+
+ class _StreamingEngine:
+ async def step_streaming(self, request):
+ captured["request"] = request
+ for chunk in chunks:
+ yield [chunk]
+
+ stage_proc = object.__new__(StageDiffusionProc)
+ stage_proc._engine = _StreamingEngine()
+
+ outputs = [
+ output
+ async for output in stage_proc._process_streaming_request(
+ request_id="req-stream",
+ prompt="prompt",
+ sampling_params_dict=asdict(OmniDiffusionSamplingParams()),
+ kv_sender_info={0: {"host": "127.0.0.1"}},
+ )
+ ]
+
+ assert outputs == chunks
+ assert [output.request_id for output in outputs] == ["req-stream", "req-stream"]
+ assert [output.finished for output in outputs] == [False, True]
+ assert captured["request"].kv_sender_info == {0: {"host": "127.0.0.1"}}
+
+
@pytest.mark.asyncio
async def test_proc_process_request_with_batching_async_output():
stage_proc = object.__new__(StageDiffusionProc)
diff --git a/tests/e2e/accuracy/helpers.py b/tests/e2e/accuracy/helpers.py
index 593a9960a..ebe635ea8 100644
--- a/tests/e2e/accuracy/helpers.py
+++ b/tests/e2e/accuracy/helpers.py
@@ -305,7 +305,7 @@ def assert_video_similarity_metrics(
offline_path: Path,
ssim_threshold: float,
psnr_threshold: float,
-) -> None:
+) -> tuple[float, float]:
ssim_output = run_ffmpeg_similarity("ssim", online_path, offline_path)
psnr_output = run_ffmpeg_similarity("psnr", online_path, offline_path)
ssim_score = parse_ssim_score(ssim_output)
@@ -339,6 +339,7 @@ def assert_video_similarity_metrics(
f"PSNR below threshold: got {psnr_score:.6f}, expected >= {psnr_threshold:.6f}. "
f"online={online_path} offline={offline_path}"
)
+ return ssim_score, psnr_score
def assert_similarity(
diff --git a/tests/e2e/accuracy/test_video_streaming_output_similarity.py b/tests/e2e/accuracy/test_video_streaming_output_similarity.py
new file mode 100644
index 000000000..799e6e125
--- /dev/null
+++ b/tests/e2e/accuracy/test_video_streaming_output_similarity.py
@@ -0,0 +1,143 @@
+"""
+Online serving smoke test for streaming video output similarity.
+"""
+
+import json
+from pathlib import Path
+
+import pytest
+
+from tests.e2e.accuracy.helpers import assert_video_similarity_metrics, probe_video
+from tests.helpers.mark import hardware_marks
+from tests.helpers.runtime import DiffusionResponse, OmniServer, OpenAIClientHandler
+
+pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
+
+MODEL = "BestWishYsh/Helios-Distilled"
+PROMPT = "A serene lakeside sunrise with mist over the water."
+WIDTH = 640
+HEIGHT = 384
+FPS = 16
+NUM_FRAMES = 65
+NUM_INFERENCE_STEPS = 5
+SEED = 42
+MIN_PSNR_DB = 28.0
+MIN_SSIM = 0.95
+
+SINGLE_CARD_MARKS = hardware_marks(res={"cuda": "H100"})
+
+HELIOS_DISTILLED_EXTRA_PARAMS = {
+ "is_enable_stage2": True,
+ "pyramid_num_stages": 3,
+ "pyramid_num_inference_steps_list": [1, 1, 1],
+ "is_amplify_first_chunk": True,
+}
+
+
+def _server_args(*, streaming_output: bool) -> list[str]:
+ args = [
+ "--stage-init-timeout",
+ "600",
+ "--init-timeout",
+ "900",
+ "--log-stats",
+ ]
+ if streaming_output:
+ args.append("--diffusion-streaming-output")
+ return args
+
+
+def _request_config(model: str, *, serialize_extra_params: bool) -> dict:
+ extra_params = (
+ json.dumps(HELIOS_DISTILLED_EXTRA_PARAMS) if serialize_extra_params else HELIOS_DISTILLED_EXTRA_PARAMS
+ )
+ return {
+ "model": model,
+ "form_data": {
+ "prompt": PROMPT,
+ "width": WIDTH,
+ "height": HEIGHT,
+ "fps": FPS,
+ "num_frames": NUM_FRAMES,
+ "num_inference_steps": NUM_INFERENCE_STEPS,
+ "guidance_scale": 1.0,
+ "seed": SEED,
+ "extra_params": extra_params,
+ },
+ }
+
+
+def _client_for(server: OmniServer, *, run_level: str) -> OpenAIClientHandler:
+ return OpenAIClientHandler(
+ host=server.host,
+ port=server.port,
+ api_key="EMPTY",
+ run_level=run_level,
+ log_stats=server.log_stats,
+ )
+
+
+def _write_video_artifact(response: DiffusionResponse, output_path: Path) -> Path:
+ assert response.videos, "No video artifact returned"
+ output_path.write_bytes(response.videos[0])
+ return output_path
+
+
+@pytest.mark.parametrize(
+ "model",
+ [
+ pytest.param(
+ MODEL,
+ id="helios_distilled",
+ marks=SINGLE_CARD_MARKS,
+ )
+ ],
+)
+def test_helios_streaming_output_matches_non_streaming(
+ model: str,
+ model_prefix: str,
+ run_level: str,
+ tmp_path: Path,
+ record_property,
+):
+ model = model_prefix + model
+
+ with OmniServer(model, _server_args(streaming_output=True), use_omni=True) as server:
+ client = _client_for(server, run_level=run_level)
+ streaming_response = client.send_streaming_video_diffusion_request(
+ _request_config(server.model, serialize_extra_params=False)
+ )[0]
+
+ streaming_path = _write_video_artifact(streaming_response, tmp_path / "helios_streaming.mp4")
+ record_property("helios_streaming_video", str(streaming_path))
+ record_property("helios_streaming_latency_s", streaming_response.e2e_latency)
+
+ with OmniServer(model, _server_args(streaming_output=False), use_omni=True) as server:
+ client = _client_for(server, run_level=run_level)
+ non_streaming_response = client.send_video_diffusion_request(
+ _request_config(server.model, serialize_extra_params=True)
+ )[0]
+
+ non_streaming_path = _write_video_artifact(non_streaming_response, tmp_path / "helios_non_streaming.mp4")
+ record_property("helios_non_streaming_video", str(non_streaming_path))
+ record_property("helios_non_streaming_latency_s", non_streaming_response.e2e_latency)
+
+ streaming_metadata = probe_video(streaming_path)
+ non_streaming_metadata = probe_video(non_streaming_path)
+ assert streaming_metadata == non_streaming_metadata, (
+ f"Video metadata mismatch for helios_streaming_output:\n"
+ f"streaming={streaming_metadata}\n"
+ f"non_streaming={non_streaming_metadata}\n"
+ f"streaming_path={streaming_path}\n"
+ f"non_streaming_path={non_streaming_path}"
+ )
+
+ ssim, psnr = assert_video_similarity_metrics(
+ label="helios_streaming_output",
+ online_path=streaming_path,
+ offline_path=non_streaming_path,
+ ssim_threshold=MIN_SSIM,
+ psnr_threshold=MIN_PSNR_DB,
+ )
+ record_property("helios_streaming_vs_non_streaming_psnr_db", psnr)
+ record_property("helios_streaming_vs_non_streaming_ssim_percent", ssim * 100.0)
diff --git a/tests/engine/test_orchestrator.py b/tests/engine/test_orchestrator.py
index f73504074..23f8cfaa2 100644
--- a/tests/engine/test_orchestrator.py
+++ b/tests/engine/test_orchestrator.py
@@ -523,6 +523,67 @@ async def test_run_single_stage_diffusion(orchestrator_factory) -> None:
await _shutdown_orchestrator(orchestrator_fixture)
+@pytest.mark.asyncio
+async def test_run_single_stage_diffusion_streaming_forwards_intermediate_chunks(orchestrator_factory) -> None:
+ """Intermediate diffusion chunks (finished=False) reach the frontend before the final chunk."""
+ stage0 = FakeStageClient(stage_type="diffusion", final_output=True, final_output_type="image")
+ orchestrator_fixture = orchestrator_factory([stage0])
+ params = OmniDiffusionSamplingParams()
+
+ try:
+ await _enqueue_add_request(
+ orchestrator_fixture,
+ request_id="req-stream",
+ prompt={"prompt": "draw a cat"},
+ original_prompt={"prompt": "draw a cat"},
+ sampling_params_list=[params],
+ final_stage_id=0,
+ )
+
+ await _wait_for(lambda: len(stage0.add_request_calls) == 1)
+ stage0.push_diffusion_output(
+ OmniRequestOutput.from_diffusion(
+ request_id="req-stream",
+ images=[],
+ final_output_type="image",
+ custom_output={"chunk": 0},
+ finished=False,
+ )
+ )
+ stage0.push_diffusion_output(
+ OmniRequestOutput.from_diffusion(
+ request_id="req-stream",
+ images=[],
+ final_output_type="image",
+ custom_output={"chunk": 1},
+ finished=True,
+ )
+ )
+
+ output_msgs: list[OutputMessage] = []
+ deadline = time.monotonic() + 2.0
+ while not output_msgs or not output_msgs[-1].finished:
+ if time.monotonic() >= deadline:
+ raise AssertionError(
+ f"Timed out waiting for finished orchestrator output, got {len(output_msgs)} message(s)"
+ )
+ try:
+ msg = orchestrator_fixture.output_sync_q.get_nowait()
+ except queue.Empty:
+ await asyncio.sleep(0.01)
+ continue
+ if isinstance(msg, OutputMessage):
+ output_msgs.append(msg)
+
+ assert [msg.request_id for msg in output_msgs] == ["req-stream", "req-stream"]
+ assert [msg.finished for msg in output_msgs] == [False, True]
+ assert [msg.engine_outputs.finished for msg in output_msgs] == [False, True]
+ assert [msg.engine_outputs.custom_output["chunk"] for msg in output_msgs] == [0, 1]
+ await _wait_for(lambda: "req-stream" not in orchestrator_fixture.orchestrator.request_states)
+ finally:
+ await _shutdown_orchestrator(orchestrator_fixture)
+
+
@pytest.mark.asyncio
async def test_run_llm_to_diffusion(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=False)
diff --git a/tests/entrypoints/openai_api/test_serving_video_output_stream.py b/tests/entrypoints/openai_api/test_serving_video_output_stream.py
new file mode 100644
index 000000000..72d293853
--- /dev/null
+++ b/tests/entrypoints/openai_api/test_serving_video_output_stream.py
@@ -0,0 +1,446 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Unit tests for `/v1/realtime/video` WebSocket video output streaming."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from collections.abc import AsyncGenerator, Callable
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import numpy as np
+import pytest
+from fastapi import FastAPI, WebSocket
+from pytest_mock import MockerFixture
+from starlette.testclient import TestClient
+
+from vllm_omni.entrypoints.openai.serving_video_output_stream import OmniStreamingVideoOutputHandler
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams
+from vllm_omni.outputs import OmniRequestOutput
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+def _fake_video_frames(num_frames: int = 2) -> list[np.ndarray]:
+ return [np.full((4, 4, 3), fill_value=i, dtype=np.uint8) for i in range(num_frames)]
+
+
+def _build_test_app(
+ *,
+ mocker: MockerFixture,
+ streaming_chunks: list[tuple[bytes, bool]],
+ mock_generate: Callable[..., AsyncGenerator[OmniRequestOutput, None]],
+ final_chunk: bytes = b"",
+ stall_timeout: float = 5.0,
+) -> tuple[FastAPI, OmniStreamingVideoOutputHandler, MagicMock]:
+ engine_client = mocker.MagicMock()
+ engine_client.abort = mocker.AsyncMock()
+ engine_client.default_sampling_params_list = [OmniDiffusionSamplingParams()]
+ engine_client.stage_configs = [SimpleNamespace(stage_type="diffusion")]
+ engine_client.generate = mock_generate
+
+ class FakeStreamingVideoEncoder:
+ encode_calls: list[int] = []
+
+ def encode(self, video):
+ del video
+ idx = len(self.encode_calls)
+ self.encode_calls.append(idx)
+ return streaming_chunks[idx][0]
+
+ def close(self):
+ return final_chunk
+
+ def _make_encoder(*, output_format, fps, video_codec_options=None):
+ del output_format, fps, video_codec_options
+ return FakeStreamingVideoEncoder()
+
+ encoder_factory = mocker.MagicMock(side_effect=_make_encoder)
+ engine_client.streaming_encoder_factory = encoder_factory
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.create_streaming_video_encoder",
+ encoder_factory,
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.get_stage_type",
+ return_value="diffusion",
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.build_stage_sampling_params_list",
+ return_value=[OmniDiffusionSamplingParams()],
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.get_default_sampling_params_list",
+ return_value=[OmniDiffusionSamplingParams()],
+ )
+
+ handler = OmniStreamingVideoOutputHandler(
+ engine_client=engine_client,
+ model_name="test-model",
+ stage_configs=engine_client.stage_configs,
+ stall_timeout=stall_timeout,
+ start_timeout=5.0,
+ )
+ app = FastAPI()
+
+ @app.websocket("/v1/realtime/video")
+ async def ws_endpoint(websocket: WebSocket):
+ await handler.handle_session(websocket)
+
+ return app, handler, engine_client
+
+
+class TestStreamingVideoOutputWebSocket:
+ """WebSocket protocol tests for streaming generated video output."""
+
+ def test_streaming_session_emits_video_start_binary_chunks_and_done(self, mocker: MockerFixture):
+ """A full session delivers video.start, binary chunks, then session.done."""
+
+ async def mock_generate(*_args, **_kwargs):
+ for frames, finished in [
+ (_fake_video_frames(2), False),
+ (_fake_video_frames(3), True),
+ ]:
+ yield OmniRequestOutput.from_diffusion(
+ request_id="req-ws",
+ images=[frames],
+ final_output_type="image",
+ finished=finished,
+ )
+
+ app, _handler, engine_client = _build_test_app(
+ mocker=mocker,
+ streaming_chunks=[(b"mp4-chunk-0", False), (b"mp4-chunk-1", True)],
+ mock_generate=mock_generate,
+ )
+
+ with TestClient(app) as client:
+ with client.websocket_connect("/v1/realtime/video") as ws:
+ ws.send_json({"type": "session.start", "prompt": "A cat walking in the rain"})
+
+ start = ws.receive_json()
+ assert start["type"] == "video.start"
+ assert start["format"] == "m4s"
+ assert "format" not in start["config"]
+ assert "request_id" in start
+
+ assert ws.receive_bytes() == b"mp4-chunk-0"
+ assert ws.receive_bytes() == b"mp4-chunk-1"
+
+ done = ws.receive_json()
+ assert done["type"] == "session.done"
+ assert done["chunks"] == 2
+ assert done["stopped"] is False
+
+ engine_client.abort.assert_not_called()
+ engine_client.streaming_encoder_factory.assert_called_once()
+ assert engine_client.streaming_encoder_factory.call_args.kwargs["output_format"] == "m4s"
+
+ def test_streaming_session_rejects_unknown_format(self, mocker: MockerFixture):
+ async def mock_generate(*_args, **_kwargs):
+ if False:
+ yield None
+
+ app, _handler, engine_client = _build_test_app(
+ mocker=mocker,
+ streaming_chunks=[],
+ mock_generate=mock_generate,
+ )
+
+ with TestClient(app) as client:
+ with client.websocket_connect("/v1/realtime/video") as ws:
+ ws.send_json({"type": "session.start", "prompt": "hello", "format": "webm"})
+ err = ws.receive_json()
+ assert err["type"] == "error"
+ assert "format" in err["message"]
+
+ engine_client.abort.assert_not_called()
+
+ def test_streaming_session_emits_final_encoder_delta_before_done(self, mocker: MockerFixture):
+ """A non-empty encoder close delta is delivered as the final binary chunk."""
+
+ async def mock_generate(*_args, **_kwargs):
+ yield OmniRequestOutput.from_diffusion(
+ request_id="req-ws",
+ images=[_fake_video_frames(2)],
+ final_output_type="image",
+ finished=True,
+ )
+
+ app, _handler, engine_client = _build_test_app(
+ mocker=mocker,
+ streaming_chunks=[(b"mp4-chunk-0", True)],
+ mock_generate=mock_generate,
+ final_chunk=b"mp4-trailer",
+ )
+
+ with TestClient(app) as client:
+ with client.websocket_connect("/v1/realtime/video") as ws:
+ ws.send_json({"type": "session.start", "prompt": "A cat walking in the rain"})
+ assert ws.receive_json()["type"] == "video.start"
+ assert ws.receive_bytes() == b"mp4-chunk-0"
+ assert ws.receive_bytes() == b"mp4-trailer"
+
+ done = ws.receive_json()
+ assert done["type"] == "session.done"
+ assert done["chunks"] == 2
+ assert done["stopped"] is False
+
+ engine_client.abort.assert_not_called()
+
+ def test_generation_error_output_returns_websocket_error(self, mocker: MockerFixture):
+ """An erroneous OmniRequestOutput from generate is sent to the client as a WebSocket error."""
+ error_message = "diffusion engine exploded"
+
+ async def mock_generate(*_args, **_kwargs):
+ yield OmniRequestOutput.from_diffusion(
+ request_id="req-ws",
+ images=[_fake_video_frames(2)],
+ final_output_type="image",
+ finished=False,
+ )
+ yield OmniRequestOutput.from_error("req-ws", error_message)
+
+ app, _handler, engine_client = _build_test_app(
+ mocker=mocker,
+ streaming_chunks=[(b"mp4-chunk-0", False)],
+ mock_generate=mock_generate,
+ )
+
+ with TestClient(app) as client:
+ with client.websocket_connect("/v1/realtime/video") as ws:
+ ws.send_json({"type": "session.start", "prompt": "hello"})
+ assert ws.receive_json()["type"] == "video.start"
+ assert ws.receive_bytes() == b"mp4-chunk-0"
+ err = ws.receive_json()
+ assert err["type"] == "error"
+ assert error_message in err["message"]
+
+ engine_client.abort.assert_not_called()
+
+
+class _MockVideoOutputWebSocket:
+ """Feeds scripted client messages and records server sends."""
+
+ def __init__(self, messages: list[str]) -> None:
+ self._messages: asyncio.Queue[str] = asyncio.Queue()
+ for message in messages:
+ self._messages.put_nowait(message)
+ self.sent: list[dict | bytes] = []
+
+ def enqueue(self, message: str) -> None:
+ self._messages.put_nowait(message)
+
+ async def accept(self) -> None:
+ return None
+
+ async def receive_text(self) -> str:
+ return await self._messages.get()
+
+ async def send_json(self, data: dict) -> None:
+ self.sent.append(data)
+
+ async def send_bytes(self, data: bytes) -> None:
+ self.sent.append(data)
+
+ @property
+ def sent_json(self) -> list[dict]:
+ return [m for m in self.sent if isinstance(m, dict)]
+
+
+def _build_handler_for_async_tests(
+ mocker: MockerFixture,
+ *,
+ mock_generate: Callable[..., AsyncGenerator[OmniRequestOutput, None]],
+ streaming_chunks: list[tuple[bytes, bool]] | None = None,
+ stall_timeout: float = 5.0,
+) -> tuple[OmniStreamingVideoOutputHandler, MagicMock, _MockVideoOutputWebSocket]:
+ engine_client = mocker.MagicMock()
+ engine_client.abort = mocker.AsyncMock()
+ engine_client.default_sampling_params_list = [OmniDiffusionSamplingParams()]
+ engine_client.stage_configs = [SimpleNamespace(stage_type="diffusion")]
+ engine_client.generate = mock_generate
+
+ chunks = streaming_chunks or [(b"mp4-chunk-0", True)]
+
+ class FakeStreamingVideoEncoder:
+ def __init__(self) -> None:
+ self.encode_calls = 0
+
+ def encode(self, video):
+ del video
+ idx = self.encode_calls
+ self.encode_calls += 1
+ return chunks[idx][0]
+
+ def close(self):
+ return b""
+
+ encoder_factory = mocker.MagicMock(side_effect=lambda **_: FakeStreamingVideoEncoder())
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.create_streaming_video_encoder",
+ encoder_factory,
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.get_stage_type",
+ return_value="diffusion",
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.build_stage_sampling_params_list",
+ return_value=[OmniDiffusionSamplingParams()],
+ )
+ mocker.patch(
+ "vllm_omni.entrypoints.openai.serving_video_output_stream.get_default_sampling_params_list",
+ return_value=[OmniDiffusionSamplingParams()],
+ )
+
+ handler = OmniStreamingVideoOutputHandler(
+ engine_client=engine_client,
+ model_name="test-model",
+ stage_configs=engine_client.stage_configs,
+ stall_timeout=stall_timeout,
+ start_timeout=5.0,
+ )
+ start_msg = json.dumps({"type": "session.start", "prompt": "async test"})
+ ws = _MockVideoOutputWebSocket([start_msg])
+ return handler, engine_client, ws
+
+
+class TestStreamingVideoOutputStallTimeout:
+ """Stall-timeout and session.ping behavior."""
+
+ def test_long_generation_without_client_messages_succeeds(self, mocker: MockerFixture):
+ """Engine progress between yields keeps the session alive without client pings."""
+
+ async def mock_generate(*_args, **_kwargs):
+ yield OmniRequestOutput.from_diffusion(
+ request_id="req-ws",
+ images=[_fake_video_frames(2)],
+ final_output_type="image",
+ finished=False,
+ )
+ await asyncio.sleep(0.3)
+ yield OmniRequestOutput.from_diffusion(
+ request_id="req-ws",
+ images=[_fake_video_frames(2)],
+ final_output_type="image",
+ finished=True,
+ )
+
+ app, _handler, engine_client = _build_test_app(
+ mocker=mocker,
+ streaming_chunks=[(b"mp4-chunk-0", False), (b"mp4-chunk-1", True)],
+ mock_generate=mock_generate,
+ stall_timeout=0.5,
+ )
+
+ with TestClient(app) as client:
+ with client.websocket_connect("/v1/realtime/video") as ws:
+ ws.send_json({"type": "session.start", "prompt": "slow generation"})
+ assert ws.receive_json()["type"] == "video.start"
+ assert ws.receive_bytes() == b"mp4-chunk-0"
+ assert ws.receive_bytes() == b"mp4-chunk-1"
+ done = ws.receive_json()
+ assert done["type"] == "session.done"
+ assert done["stopped"] is False
+
+ engine_client.abort.assert_not_called()
+
+ def test_stall_timeout_aborts_when_engine_silent(self, mocker: MockerFixture):
+ """No engine output for longer than stall_timeout aborts the request."""
+
+ async def mock_generate(*_args, **_kwargs):
+ await asyncio.sleep(1.0)
+ yield OmniRequestOutput.from_diffusion(
+ request_id="req-ws",
+ images=[_fake_video_frames(2)],
+ final_output_type="image",
+ finished=True,
+ )
+
+ app, _handler, engine_client = _build_test_app(
+ mocker=mocker,
+ streaming_chunks=[(b"mp4-chunk-0", True)],
+ mock_generate=mock_generate,
+ stall_timeout=0.2,
+ )
+
+ with TestClient(app) as client:
+ with client.websocket_connect("/v1/realtime/video") as ws:
+ ws.send_json({"type": "session.start", "prompt": "stalled"})
+ assert ws.receive_json()["type"] == "video.start"
+ err = ws.receive_json()
+ assert err["type"] == "error"
+ assert "Stall timeout" in err["message"]
+
+ engine_client.abort.assert_called()
+
+ @pytest.mark.asyncio
+ async def test_session_ping_returns_pong(self, mocker: MockerFixture):
+ """session.ping during generation receives session.pong."""
+
+ async def mock_generate(*_args, **_kwargs):
+ await asyncio.sleep(0.5)
+ yield OmniRequestOutput.from_diffusion(
+ request_id="req-ws",
+ images=[_fake_video_frames(2)],
+ final_output_type="image",
+ finished=True,
+ )
+
+ handler, _engine_client, ws = _build_handler_for_async_tests(
+ mocker,
+ mock_generate=mock_generate,
+ stall_timeout=5.0,
+ )
+
+ async def _send_ping_after_delay() -> None:
+ await asyncio.sleep(0.05)
+ ws.enqueue(json.dumps({"type": "session.ping"}))
+
+ ping_task = asyncio.create_task(_send_ping_after_delay())
+ await handler.handle_session(ws) # type: ignore[arg-type]
+ await ping_task
+
+ assert any(m.get("type") == "session.pong" for m in ws.sent_json)
+
+ @pytest.mark.asyncio
+ async def test_ping_prevents_stall_during_silent_engine(self, mocker: MockerFixture):
+ """Client pings refresh the stall clock while the engine produces no output."""
+
+ release = asyncio.Event()
+
+ async def mock_generate(*_args, **_kwargs):
+ await release.wait()
+ yield OmniRequestOutput.from_diffusion(
+ request_id="req-ws",
+ images=[_fake_video_frames(2)],
+ final_output_type="image",
+ finished=True,
+ )
+
+ handler, engine_client, ws = _build_handler_for_async_tests(
+ mocker,
+ mock_generate=mock_generate,
+ stall_timeout=0.3,
+ )
+
+ async def _pinger() -> None:
+ while not release.is_set():
+ ws.enqueue(json.dumps({"type": "session.ping"}))
+ await asyncio.sleep(0.1)
+
+ pinger_task = asyncio.create_task(_pinger())
+ session_task = asyncio.create_task(handler.handle_session(ws)) # type: ignore[arg-type]
+ await asyncio.sleep(0.8)
+ release.set()
+ await session_task
+ await pinger_task
+
+ errors = [m for m in ws.sent_json if m.get("type") == "error"]
+ assert not any("Stall timeout" in m.get("message", "") for m in errors)
+ assert any(m.get("type") == "session.pong" for m in ws.sent_json)
+ assert any(m.get("type") == "session.done" for m in ws.sent_json)
+ engine_client.abort.assert_not_called()
diff --git a/tests/entrypoints/openai_api/test_video_api_utils.py b/tests/entrypoints/openai_api/test_video_api_utils.py
index 9e732403f..722819682 100644
--- a/tests/entrypoints/openai_api/test_video_api_utils.py
+++ b/tests/entrypoints/openai_api/test_video_api_utils.py
@@ -48,6 +48,107 @@ def test_encode_video_bytes_exports_frames_without_interpolation(monkeypatch):
assert mux_calls[0]["audio"] is None
+def test_fragmented_mp4_video_encoder_reuses_single_muxer(monkeypatch):
+ muxers = []
+
+ class FakeFragmentedMP4Muxer:
+ def __init__(self, *, width, height, fps, video_codec_options=None):
+ self.calls = []
+ muxers.append(
+ {
+ "width": width,
+ "height": height,
+ "fps": fps,
+ "video_codec_options": video_codec_options,
+ "instance": self,
+ }
+ )
+
+ def mux_video_frames(self, frames):
+ self.calls.append(frames)
+ return f"fragment-{len(self.calls)}".encode()
+
+ def close(self):
+ return b"trailer"
+
+ monkeypatch.setattr(
+ "vllm_omni.diffusion.utils.media_utils.FragmentedMP4Muxer",
+ FakeFragmentedMP4Muxer,
+ )
+
+ encoder = video_api_utils.FragmentedMP4VideoEncoder(
+ fps=12,
+ video_codec_options={"preset": "ultrafast"},
+ )
+ assert encoder.encode([np.zeros((4, 6, 3), dtype=np.float32)]) == b"fragment-1"
+ assert encoder.encode([np.ones((4, 6, 3), dtype=np.float32)]) == b"fragment-2"
+ assert encoder.close() == b"trailer"
+
+ assert len(muxers) == 1
+ assert muxers[0]["width"] == 6
+ assert muxers[0]["height"] == 4
+ assert muxers[0]["fps"] == 12.0
+ assert muxers[0]["video_codec_options"] == {"preset": "ultrafast"}
+ assert len(muxers[0]["instance"].calls) == 2
+
+
+def test_create_streaming_video_encoder_selects_requested_format():
+ assert isinstance(
+ video_api_utils.create_streaming_video_encoder(output_format="m4s", fps=12),
+ video_api_utils.FragmentedMP4VideoEncoder,
+ )
+
+
+def test_finalize_streaming_mp4_bytes_produces_progressive_mp4():
+ """Fragment MP4 chunks are remuxed into decodable progressive MP4 bytes."""
+ import numpy as np
+
+ from vllm_omni.diffusion.utils.media_utils import FragmentedMP4Muxer, finalize_streaming_video_bytes
+
+ def _read_mp4_video_info(mp4_bytes: bytes) -> tuple[int, float, float | None, int, int]:
+ import io
+
+ import av
+
+ with av.open(io.BytesIO(mp4_bytes), mode="r", format="mp4") as container:
+ stream = container.streams.video[0]
+ frame_count = sum(1 for _ in container.decode(stream))
+ assert stream.average_rate is not None
+ fps = float(stream.average_rate)
+ duration_sec = None
+ if stream.duration is not None:
+ assert stream.time_base is not None
+ duration_sec = float(stream.duration * stream.time_base)
+ return frame_count, fps, duration_sec, stream.width, stream.height
+
+ width = 32
+ height = 32
+ fps = 16.0
+ input_frame_count = 2
+
+ muxer = FragmentedMP4Muxer(width=width, height=height, fps=fps)
+ frames = np.zeros((input_frame_count, height, width, 3), dtype=np.uint8)
+ streamed = muxer.mux_video_frames(frames) + muxer.close()
+
+ finalized = finalize_streaming_video_bytes(streamed, input_format="m4s", fps=fps)
+ assert finalized
+ assert finalized != streamed
+
+ streamed_info = _read_mp4_video_info(streamed)
+ final_info = _read_mp4_video_info(finalized)
+ expected_duration = input_frame_count / fps
+
+ assert streamed_info[0] == input_frame_count
+ assert final_info[0] == input_frame_count
+ assert streamed_info[1] == pytest.approx(fps)
+ assert final_info[1] == pytest.approx(fps)
+ assert streamed_info[3:] == (width, height)
+ assert final_info[3:] == (width, height)
+
+ assert final_info[2] == pytest.approx(expected_duration, rel=0.05)
+ assert streamed_info[2] == pytest.approx(expected_duration, rel=0.05)
+
+
def test_rife_model_inference_runs_on_dummy_tensors():
model = rife_interpolator.Model().eval()
img0 = torch.rand(1, 3, 32, 32)
diff --git a/tests/entrypoints/openai_api/test_video_server.py b/tests/entrypoints/openai_api/test_video_server.py
index 7e6fa2a73..94b17b2b8 100644
--- a/tests/entrypoints/openai_api/test_video_server.py
+++ b/tests/entrypoints/openai_api/test_video_server.py
@@ -1334,7 +1334,7 @@ def test_extra_params_merged_into_extra_args(test_client, mocker: MockerFixture)
extra_params = {
"is_enable_stage2": True,
"pyramid_num_stages": 3,
- "pyramid_num_inference_steps_list": [20, 20, 20],
+ "pyramid_num_inference_steps_list": [1, 1, 1],
"use_cfg_zero_star": True,
}
response = test_client.post(
@@ -1352,7 +1352,7 @@ def test_extra_params_merged_into_extra_args(test_client, mocker: MockerFixture)
captured = engine.captured_sampling_params_list[0]
assert captured.extra_args["is_enable_stage2"] is True
assert captured.extra_args["pyramid_num_stages"] == 3
- assert captured.extra_args["pyramid_num_inference_steps_list"] == [20, 20, 20]
+ assert captured.extra_args["pyramid_num_inference_steps_list"] == [1, 1, 1]
assert captured.extra_args["use_cfg_zero_star"] is True
diff --git a/tests/entrypoints/test_async_omni.py b/tests/entrypoints/test_async_omni.py
index b8637c1e9..0d7700e27 100644
--- a/tests/entrypoints/test_async_omni.py
+++ b/tests/entrypoints/test_async_omni.py
@@ -8,6 +8,7 @@ from vllm.sampling_params import RequestOutputKind, SamplingParams
from tests.helpers.mark import hardware_test
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.entrypoints.async_omni import AsyncOmni
+from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.core_model]
@@ -198,6 +199,48 @@ def test_generate_accepts_request_after_repeated_cancellations():
asyncio.run(run_test())
+@pytest.mark.cpu
+def test_generate_yields_streaming_diffusion_chunks_before_final():
+ """AsyncOmni.generate yields every intermediate diffusion chunk before the final one."""
+
+ async def streaming_process_results(request_id, metrics, final_stage_id_for_e2e, req_start_ts, wall_start_ts):
+ del metrics, final_stage_id_for_e2e, req_start_ts, wall_start_ts
+ yield OmniRequestOutput.from_diffusion(
+ request_id=request_id,
+ images=[],
+ final_output_type="image",
+ custom_output={"chunk": 0},
+ finished=False,
+ )
+ yield OmniRequestOutput.from_diffusion(
+ request_id=request_id,
+ images=[],
+ final_output_type="image",
+ custom_output={"chunk": 1},
+ finished=True,
+ )
+
+ async def run_test():
+ omni = get_async_omni_instance()
+ omni._process_orchestrator_results = streaming_process_results
+
+ outputs = []
+ async for output in AsyncOmni.generate(
+ omni,
+ prompt={"prompt": "a cat"},
+ request_id="req-stream",
+ sampling_params_list=[SimpleNamespace()],
+ output_modalities=["image"],
+ ):
+ outputs.append(output)
+
+ assert len(outputs) == 2
+ assert [output.finished for output in outputs] == [False, True]
+ assert [output.custom_output["chunk"] for output in outputs] == [0, 1]
+
+ asyncio.run(run_test())
+
+
@pytest.mark.cpu
@pytest.mark.parametrize(
"output_kind", [RequestOutputKind.DELTA, RequestOutputKind.FINAL_ONLY, RequestOutputKind.CUMULATIVE]
diff --git a/tests/helpers/assertions.py b/tests/helpers/assertions.py
index da62ceab2..de77722b3 100644
--- a/tests/helpers/assertions.py
+++ b/tests/helpers/assertions.py
@@ -115,6 +115,11 @@ def assert_video_diffusion_response(
expected_height = _maybe_int(form_data.get("height"))
expected_fps = _maybe_int(form_data.get("fps"))
+ # Skip num_frames assertion for Helios models because they round up frames
+ model = request_config.get("model", "")
+ if "Helios" in model:
+ expected_frames = None
+
for vid_bytes in response.videos:
assert_video_valid(
vid_bytes,
diff --git a/tests/helpers/runtime.py b/tests/helpers/runtime.py
index c12154f86..08be653fd 100644
--- a/tests/helpers/runtime.py
+++ b/tests/helpers/runtime.py
@@ -19,7 +19,7 @@ from collections.abc import Generator
from dataclasses import asdict, dataclass
from io import BytesIO
from pathlib import Path
-from typing import Any, NamedTuple
+from typing import Any, NamedTuple, cast
from urllib.parse import quote
import psutil
@@ -2168,6 +2168,102 @@ class OpenAIClientHandler:
timeout=timeout,
)
+ def send_streaming_video_diffusion_request(
+ self,
+ request_config: dict[str, Any],
+ request_num: int = 1,
+ *,
+ timeout_seconds: float = 600.0,
+ ) -> list[DiffusionResponse]:
+ """
+ Send a native ``/v1/realtime/video`` WebSocket request and return one
+ finalized MP4 artifact assembled from the streamed binary fragments.
+ """
+ if request_num != 1:
+ raise NotImplementedError("Concurrent streaming video diffusion requests are not currently implemented")
+
+ response = asyncio.run(
+ self._send_streaming_video_diffusion_request_once(
+ request_config,
+ timeout_seconds=timeout_seconds,
+ )
+ )
+ assert_diffusion_response(response, request_config, run_level=self.run_level)
+ if response.e2e_latency is not None:
+ self._print_client_stat(f"[diffusion.stream] request#1 success in {response.e2e_latency:.3f}s")
+ else:
+ self._print_client_stat("[diffusion.stream] request#1 completed")
+ return [response]
+
+ async def _send_streaming_video_diffusion_request_once(
+ self,
+ request_config: dict[str, Any],
+ *,
+ timeout_seconds: float,
+ ) -> DiffusionResponse:
+ form_data = request_config.get("form_data")
+ if not isinstance(form_data, dict):
+ raise ValueError("Video request_config must contain 'form_data'")
+ payload: dict[str, Any] = {
+ "type": "session.start",
+ **{key: value for key, value in form_data.items() if value is not None},
+ }
+ model = request_config.get("model")
+ if model is not None:
+ payload["model"] = model
+ payload.setdefault("format", "m4s")
+
+ fps = float(payload.get("fps") or 16)
+ stream_format = payload["format"]
+ url = self._build_ws_url("/v1/realtime/video")
+
+ result = DiffusionResponse()
+ chunks: list[bytes] = []
+ start_time = time.perf_counter()
+ deadline = start_time + timeout_seconds
+
+ import websockets
+
+ async with websockets.connect(url, max_size=None) as websocket:
+ await websocket.send(json.dumps(payload))
+
+ while True:
+ remaining = deadline - time.perf_counter()
+ if remaining <= 0:
+ raise TimeoutError(f"Streaming video request did not complete within {timeout_seconds}s")
+
+ message = await asyncio.wait_for(websocket.recv(), timeout=remaining)
+ if isinstance(message, bytes):
+ chunks.append(message)
+ continue
+
+ msg = json.loads(message)
+ msg_type = msg.get("type")
+ if msg_type == "video.start":
+ stream_format = msg.get("format") or stream_format
+ continue
+ if msg_type == "session.done":
+ break
+ if msg_type == "error":
+ raise RuntimeError(str(msg.get("message", msg)))
+
+ from vllm_omni.diffusion.utils.media_utils import finalize_streaming_video_bytes
+ from vllm_omni.entrypoints.openai.video_api_utils import StreamingVideoFormat
+
+ streamed_bytes = b"".join(chunks)
+ if not streamed_bytes:
+ raise RuntimeError("Streaming video request completed without binary video chunks")
+ result.videos = [
+ finalize_streaming_video_bytes(
+ streamed_bytes,
+ input_format=cast(StreamingVideoFormat, stream_format),
+ fps=fps,
+ )
+ ]
+ result.e2e_latency = time.perf_counter() - start_time
+ result.success = True
+ return result
+
def _wait_until_video_completed(
self, video_id: str, poll_interval_seconds: int = 2, timeout_seconds: int = 300
) -> None:
diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py
index 6a25c6dab..421a42ed0 100644
--- a/vllm_omni/diffusion/data.py
+++ b/vllm_omni/diffusion/data.py
@@ -689,6 +689,9 @@ class OmniDiffusionConfig:
# Step mode settings
step_execution: bool = False
+ # Streaming mode settings
+ streaming_output: bool = False # Start (video) generation with initial prompt, but streaming output in chunks
+
# Maximum number of sequences to generate in a batch
max_num_seqs: int = 1
@@ -1164,6 +1167,11 @@ class DiffusionOutput:
# logged timings info, directly from Req.timings
# timings: Optional["RequestTimings"] = None
+ # Streaming info (the defaults should make sense for non-streaming mode)
+ finished: bool = True
+ chunk_index: int = 0
+ total_chunks: int = 1
+
# logged duration of stages
stage_durations: dict[str, float] = field(default_factory=dict)
diff --git a/vllm_omni/diffusion/diffusion_engine.py b/vllm_omni/diffusion/diffusion_engine.py
index a7869b140..1d8753682 100644
--- a/vllm_omni/diffusion/diffusion_engine.py
+++ b/vllm_omni/diffusion/diffusion_engine.py
@@ -9,7 +9,7 @@ import inspect
import queue
import threading
import time
-from collections.abc import Iterable
+from collections.abc import AsyncGenerator, Iterable
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any
@@ -126,9 +126,14 @@ class DiffusionEngine:
self.action_post_process_func, "custom_output"
)
+ self.step_execution = bool(getattr(od_config, "step_execution", False))
+ if self.od_config.streaming_output and not self.step_execution:
+ logger.warning("streaming_output=True requires step_execution=True; enabling step execution.")
+ self.od_config.step_execution = True
+ self.step_execution = True
+
executor_class = DiffusionExecutor.get_class(od_config)
self.executor = executor_class(od_config)
- self.step_execution = bool(getattr(od_config, "step_execution", False))
self.scheduler: SchedulerInterface = scheduler or (
StepScheduler() if self.step_execution else RequestScheduler()
)
@@ -149,11 +154,15 @@ class DiffusionEngine:
self._rpc_lock = threading.RLock()
self._cv = threading.Condition(self._rpc_lock)
self._out_queue: dict[str, asyncio.Future] = {}
+ self._out_queue_streaming: dict[str, asyncio.Queue[DiffusionOutput]] = {}
self._closed = False
self._shutdown_complete = False
self.abort_queue: queue.Queue[str] = queue.Queue()
self._rpc_queue: queue.Queue[_RpcTask] = queue.Queue()
- self.execute_fn = self.executor.execute_step if self.step_execution else self.executor.execute_request
+ if self.step_execution:
+ self.execute_fn = self.executor.execute_step
+ else:
+ self.execute_fn = self.executor.execute_request
try:
self._dummy_run()
@@ -197,7 +206,37 @@ class DiffusionEngine:
exec_start_time = time.perf_counter()
output = await self.async_add_req_and_wait_for_response(request)
exec_total_time = time.perf_counter() - exec_start_time
+ return self.postprocess_output(request, output, diffusion_engine_start_time, preprocess_time, exec_total_time)
+ async def step_streaming(self, request: OmniDiffusionRequest) -> AsyncGenerator[list[OmniRequestOutput], None]:
+ await self._check_and_start_background_loop()
+
+ diffusion_engine_start_time = time.perf_counter()
+
+ preprocess_time = 0.0
+ if self.pre_process_func is not None:
+ preprocess_start_time = time.perf_counter()
+ request = self.pre_process_func(request)
+ preprocess_time = time.perf_counter() - preprocess_start_time
+ logger.debug("Pre-processing completed in %.4f seconds", preprocess_time)
+
+ exec_start_time = time.perf_counter()
+ generator = self.async_add_req_and_stream_response(request)
+ async for output in generator:
+ exec_total_time = time.perf_counter() - exec_start_time
+ yield self.postprocess_output(
+ request, output, diffusion_engine_start_time, preprocess_time, exec_total_time
+ )
+
+ def postprocess_output(
+ self,
+ request: OmniDiffusionRequest,
+ output: DiffusionOutput,
+ diffusion_engine_start_time: float,
+ preprocess_time: float,
+ exec_total_time: float,
+ ) -> list[OmniRequestOutput]:
+ """Convert a DiffusionOutput to a list of OmniRequestOutput, attaching profiling metrics."""
if output.aborted:
raise DiffusionRequestAbortedError(output.abort_message or "Diffusion request aborted.")
if output.error:
@@ -212,7 +251,7 @@ class DiffusionEngine:
if output.output is None:
logger.warning("Output is None, returning empty OmniRequestOutput")
- return format_empty_diffusion_outputs(request)
+ return format_empty_diffusion_outputs(request, finished=output.finished)
# When CPU offload is enabled, move output to CPU before
# post-processing to avoid device OOM — model weights may still
@@ -312,11 +351,14 @@ class DiffusionEngine:
sched_output = self.scheduler.schedule()
if sched_output.is_empty:
- self._handle_finished_requests(sched_output.finished_req_ids, None)
+ if self.od_config.streaming_output:
+ self._handle_empty_streaming_requests(sched_output.finished_req_ids)
+ else:
+ self._handle_finished_requests(sched_output.finished_req_ids, None)
continue
try:
- runner_output = self.execute_fn(sched_output)
+ runner_output: BaseRunnerOutput = self.execute_fn(sched_output) # pyright: ignore[reportAssignmentType]
except Exception as exc:
logger.error(
"Execution failed for diffusion requests %s", sched_output.scheduled_request_ids, exc_info=True
@@ -336,7 +378,14 @@ class DiffusionEngine:
self._process_aborts_queue()
self._process_rpc_queue()
finished_req_ids = self.scheduler.update_from_output(sched_output, runner_output)
- self._handle_finished_requests(finished_req_ids, runner_output)
+ if self.od_config.streaming_output:
+ self._handle_step_streaming_runner_output(
+ finished_req_ids,
+ sched_output.scheduled_request_ids,
+ runner_output,
+ )
+ else:
+ self._handle_finished_requests(finished_req_ids, runner_output)
# Engine is stopping: fail any RPCs still queued so callers don't hang.
self._fail_pending_rpcs(RuntimeError("DiffusionEngine is shutting down."))
@@ -411,6 +460,57 @@ class DiffusionEngine:
out = self._finalize_finished_request(rid, _output, missing_result_error)
self._complete_future(fut, out)
+ def _handle_empty_streaming_requests(
+ self,
+ finished_ids: set[str],
+ missing_result_error: str = "Diffusion streaming request finished without execution output.",
+ ) -> None:
+ """Mirrors `_handle_finished_requests()` in non-streaming mode when used for empty scheduler output."""
+ for rid in finished_ids:
+ out = self._finalize_finished_request(rid, None, missing_result_error=missing_result_error)
+ self._put_streaming_output_with_cv(rid, out)
+
+ def _handle_step_streaming_runner_output(
+ self,
+ finished_req_ids: set[str],
+ scheduled_request_ids: list[str],
+ runner_output: BaseRunnerOutput,
+ ) -> None:
+ """
+ Deliver partial step-execution outputs in streaming mode.
+
+ Step execution returns one ``RunnerOutput`` per scheduled request per
+ engine tick. Most denoise steps have ``result=None``; chunk boundaries
+ return a ``DiffusionOutput`` that must be delivered even before the
+ scheduler marks the request finished.
+ """
+ delivered_finished_req_ids: set[str] = set()
+
+ # finished_ids may have some requests that are not scheduler in this round.
+ # First handle this-round requests.
+ for request_id in scheduled_request_ids:
+ req_output = runner_output.get_request_output(request_id)
+ if request_id in finished_req_ids:
+ # This entire request is finished (this is the last chunk)
+ out = self._finalize_finished_request(
+ request_id,
+ req_output,
+ missing_result_error="Diffusion streaming execution finished without a final output.",
+ )
+ self._put_streaming_output_with_cv(request_id, out)
+ delivered_finished_req_ids.add(request_id)
+ elif req_output is not None and req_output.result is not None:
+ # This is a non-terminal chunk. So it is not in scheduler's finished_req_ids, but still need delivering.
+ self._put_streaming_output_with_cv(request_id, req_output.result)
+
+ # Then handle other requests that are finished in this round.
+ for request_id in finished_req_ids - delivered_finished_req_ids:
+ out = self._finalize_finished_request(
+ request_id,
+ missing_result_error="Diffusion streaming request finished without execution output.",
+ )
+ self._put_streaming_output_with_cv(request_id, out)
+
@staticmethod
def make_engine(
config: OmniDiffusionConfig,
@@ -430,9 +530,14 @@ class DiffusionEngine:
with self._cv:
if self._closed:
raise RuntimeError("DiffusionEngine is closed.")
- fut = self.main_loop.create_future()
- request_id = self.scheduler.add_request(request)
- self._out_queue[request_id] = fut
+ if not self.od_config.streaming_output:
+ fut = self.main_loop.create_future()
+ request_id = self.scheduler.add_request(request)
+ self._out_queue[request_id] = fut
+ else:
+ queue: asyncio.Queue[DiffusionOutput] = asyncio.Queue()
+ request_id = self.scheduler.add_request(request)
+ self._out_queue_streaming[request_id] = queue
self._cv.notify_all()
return request_id
@@ -448,12 +553,39 @@ class DiffusionEngine:
logger.error(f"Wait for response failed: {e}")
raise
+ async def get_streaming_result(self, request_id: str) -> AsyncGenerator[DiffusionOutput, None]:
+ """Mirrors `get_result()` in non-streaming mode."""
+ with self._cv:
+ queue = self._out_queue_streaming.get(request_id)
+ if queue is None:
+ raise RuntimeError(f"Request {request_id} not found in output queue.")
+ try:
+ while True:
+ output: DiffusionOutput = await queue.get()
+ yield output
+ if output.finished:
+ break
+ except Exception as e:
+ logger.error(f"Wait for response failed: {e}")
+ raise
+ finally:
+ # In streaming mode, an output queue is maintained until the terminal chunk is met.
+ # So unlike the non-streaming mode where output Future is popped in `_handle_finished_requests` (immediately
+ # after the request is returned), the streaming mode needs to pop the output queue here (one layer above).
+ with self._cv:
+ if self._out_queue_streaming.get(request_id) is queue:
+ self._out_queue_streaming.pop(request_id, None)
+
async def async_add_req_and_wait_for_response(self, request: OmniDiffusionRequest) -> DiffusionOutput:
# No lock needed: add_request is already protected by self._cv, and
# all executor calls are serialized inside the busy loop.
request_id = self.add_request(request)
return await self.get_result(request_id)
+ def async_add_req_and_stream_response(self, request: OmniDiffusionRequest) -> AsyncGenerator[DiffusionOutput, None]:
+ request_id = self.add_request(request)
+ return self.get_streaming_result(request_id)
+
def add_req_and_wait_for_response(self, request: OmniDiffusionRequest) -> DiffusionOutput:
with self._rpc_lock:
if self._closed:
@@ -475,7 +607,7 @@ class DiffusionEngine:
# within _dummy_run, only one request will be scheduled
request_id = sched_output.scheduled_request_ids[0]
try:
- runner_output = self.execute_fn(sched_output)
+ runner_output: BaseRunnerOutput = self.execute_fn(sched_output) # pyright: ignore[reportAssignmentType]
except EngineDeadError:
raise
except Exception as exc:
@@ -495,10 +627,10 @@ class DiffusionEngine:
if not isinstance(runner_output, RunnerOutput) and not len(runner_output) == 1:
raise ValueError("Sync func should receive one result at one time")
if target_request_id in finished_req_ids:
- runner_output = runner_output.get_request_output(target_request_id)
+ req_output = runner_output.get_request_output(target_request_id)
return self._finalize_finished_request(
target_request_id,
- runner_output=runner_output,
+ runner_output=req_output,
missing_result_error="Diffusion execution finished without a final output.",
)
@@ -693,8 +825,28 @@ class DiffusionEngine:
else:
_set_result()
+ def _put_streaming_queue_output(
+ self,
+ queue: asyncio.Queue[DiffusionOutput],
+ output: DiffusionOutput,
+ ) -> None:
+ """Append to streaming output queue in a safe event loop. Mirrors `_complete_future()` in non-streaming mode."""
+ loop = self.main_loop
+ if loop is not None and loop.is_running():
+ loop.call_soon_threadsafe(queue.put_nowait, output)
+ else:
+ queue.put_nowait(output)
+
+ def _put_streaming_output_with_cv(self, request_id: str, output: DiffusionOutput) -> None:
+ with self._cv:
+ queue = self._out_queue_streaming.get(request_id)
+ if queue is None:
+ return
+ self._put_streaming_queue_output(queue, output)
+
def close(self) -> None:
pending_futures: list[asyncio.Future] = []
+ pending_streaming_queues: list[asyncio.Queue[DiffusionOutput]] = []
with self._cv:
if self._closed and self._shutdown_complete:
return
@@ -703,12 +855,16 @@ class DiffusionEngine:
if self.stop_event is not None:
self.stop_event.set()
pending_futures = list(self._out_queue.values())
+ pending_streaming_queues = list(self._out_queue_streaming.values())
self._out_queue.clear()
+ self._out_queue_streaming.clear()
self._cv.notify_all()
closed_output = DiffusionOutput(error="DiffusionEngine is closed.")
for fut in pending_futures:
self._complete_future(fut, closed_output)
+ for streaming_queue in pending_streaming_queues:
+ self._put_streaming_queue_output(streaming_queue, closed_output)
worker_thread = self.worker_thread
if worker_thread is not None:
diff --git a/vllm_omni/diffusion/inline_stage_diffusion_client.py b/vllm_omni/diffusion/inline_stage_diffusion_client.py
index 137f2b887..a54ce8b18 100644
--- a/vllm_omni/diffusion/inline_stage_diffusion_client.py
+++ b/vllm_omni/diffusion/inline_stage_diffusion_client.py
@@ -134,12 +134,18 @@ class InlineStageDiffusionClient(StageClientBase):
kv_sender_info=kv_sender_info,
)
- results = await self._engine.step(request)
- result = results[0]
- if not result.request_id:
- result.request_id = request_id
-
- self._output_queue.put_nowait(result)
+ if self.od_config.streaming_output:
+ async for results in self._engine.step_streaming(request):
+ result = results[0]
+ if not result.request_id:
+ result.request_id = request_id
+ self._output_queue.put_nowait(result)
+ else:
+ results = await self._engine.step(request)
+ result = results[0]
+ if not result.request_id:
+ result.request_id = request_id
+ self._output_queue.put_nowait(result)
except DiffusionRequestAbortedError as e:
logger.info("request_id: %s aborted: %s", request_id, str(e))
except Exception as e:
diff --git a/vllm_omni/diffusion/models/helios/pipeline_helios.py b/vllm_omni/diffusion/models/helios/pipeline_helios.py
index 9b625b6b9..9acd13b53 100644
--- a/vllm_omni/diffusion/models/helios/pipeline_helios.py
+++ b/vllm_omni/diffusion/models/helios/pipeline_helios.py
@@ -8,7 +8,7 @@ import json
import logging
import math
import os
-from collections.abc import Iterable
+from collections.abc import Iterable, Sequence
from typing import TYPE_CHECKING, Any, ClassVar
import numpy as np
@@ -33,9 +33,10 @@ from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.platforms import current_omni_platform
if TYPE_CHECKING:
- from vllm.model_executor.layers.quantization.base_config import (
- QuantizationConfig,
- )
+ from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
+
+ from vllm_omni.diffusion.worker.input_batch import InputBatch
+ from vllm_omni.diffusion.worker.utils import DiffusionRequestState
logger = logging.getLogger(__name__)
@@ -161,6 +162,7 @@ class HeliosPipeline(
Implements chunked video generation with multi-term memory history context.
"""
+ supports_step_execution: ClassVar[bool] = True
_dit_modules: ClassVar[list[str]] = ["transformer"]
_encoder_modules: ClassVar[list[str]] = ["text_encoder"]
_vae_modules: ClassVar[list[str]] = ["vae"]
@@ -267,6 +269,640 @@ class HeliosPipeline(
def current_timestep(self):
return self._current_timestep
+ def _stage1_sigmas(self, num_steps: int) -> np.ndarray:
+ # DMD drops the last timestep in set_timesteps(). Only compensate for
+ # the one-step dummy-run edge case; otherwise preserve the historical
+ # stage-1 schedule used by request-level forward().
+ sigma_count = num_steps + 2 if self.is_distilled and num_steps == 1 else num_steps + 1
+ return np.linspace(0.999, 0.0, sigma_count)[:-1]
+
+ def prepare_encode(
+ self,
+ state: DiffusionRequestState,
+ **kwargs: Any,
+ ) -> DiffusionRequestState:
+ """Initialize Helios request state for chunk-wise step execution."""
+ del kwargs
+ req = OmniDiffusionRequest(
+ prompts=state.prompts or [],
+ sampling_params=state.sampling,
+ request_id=state.request_id,
+ )
+ extra = getattr(state.sampling, "extra_args", {}) or {}
+
+ history_sizes = sorted(extra.get("history_sizes", [16, 2, 1]), reverse=True)
+ num_latent_frames_per_chunk = int(extra.get("num_latent_frames_per_chunk", 9))
+ keep_first_frame = bool(extra.get("keep_first_frame", True))
+ frame_num = int(extra.get("frame_num", 132))
+ height = (int(state.sampling.height or extra.get("height", 384)) // 16) * 16
+ width = (int(state.sampling.width or extra.get("width", 640)) // 16) * 16
+ num_frames = max(int(state.sampling.num_frames or frame_num), 1)
+ num_steps = int(state.sampling.num_inference_steps or extra.get("num_inference_steps", 50))
+ output_type = extra.get("output_type", "np")
+
+ image = extra.get("image")
+ video = extra.get("video")
+ if image is not None and video is not None:
+ raise ValueError("image and video cannot be provided simultaneously")
+ if len(req.prompts) > 1:
+ raise ValueError("Helios step execution supports a single prompt, not a batched request.")
+
+ prompt = None
+ negative_prompt = None
+ if len(req.prompts) == 1:
+ prompt = req.prompts[0] if isinstance(req.prompts[0], str) else req.prompts[0].get("prompt")
+ negative_prompt = None if isinstance(req.prompts[0], str) else req.prompts[0].get("negative_prompt")
+ if prompt is None:
+ raise ValueError("Prompt is required for Helios generation.")
+
+ guidance_scale = float(extra.get("guidance_scale", 5.0))
+ if state.sampling.guidance_scale_provided:
+ guidance_scale = state.sampling.guidance_scale
+ self._guidance_scale = guidance_scale
+
+ device = self.device
+ dtype = self.transformer.dtype
+ generator = state.sampling.generator
+ if generator is None and state.sampling.seed is not None:
+ generator = torch.Generator(device=device).manual_seed(state.sampling.seed)
+ state.sampling.generator = generator
+
+ prompt_embeds, negative_prompt_embeds = self.encode_prompt(
+ prompt=prompt,
+ negative_prompt=negative_prompt,
+ do_classifier_free_guidance=self.do_classifier_free_guidance,
+ num_videos_per_prompt=state.sampling.num_outputs_per_prompt or 1,
+ max_sequence_length=state.sampling.max_sequence_length or 226,
+ device=device,
+ dtype=dtype,
+ )
+ batch_size = prompt_embeds.shape[0]
+
+ latents_mean = (
+ torch.tensor(self.vae.config.latents_mean)
+ .view(1, self.vae.config.z_dim, 1, 1, 1)
+ .to(self.vae.device, self.vae.dtype)
+ )
+ latents_std = 1.0 / torch.tensor(self.vae.config.latents_std).view(1, self.vae.config.z_dim, 1, 1, 1).to(
+ self.vae.device, self.vae.dtype
+ )
+
+ add_noise_to_image_latents = bool(extra.get("add_noise_to_image_latents", True))
+ image_noise_sigma_min = float(extra.get("image_noise_sigma_min", 0.111))
+ image_noise_sigma_max = float(extra.get("image_noise_sigma_max", 0.135))
+ add_noise_to_video_latents = bool(extra.get("add_noise_to_video_latents", True))
+ video_noise_sigma_min = float(extra.get("video_noise_sigma_min", 0.111))
+ video_noise_sigma_max = float(extra.get("video_noise_sigma_max", 0.135))
+
+ fake_image_latents = None
+ image_latents = None
+ if image is not None:
+ image_latents, fake_image_latents = self.prepare_image_latents(
+ image,
+ latents_mean=latents_mean,
+ latents_std=latents_std,
+ num_latent_frames_per_chunk=num_latent_frames_per_chunk,
+ dtype=torch.float32,
+ device=device,
+ generator=generator,
+ )
+
+ if image_latents is not None and add_noise_to_image_latents:
+ image_noise_sigma = (
+ torch.rand(1, device=device, generator=generator) * (image_noise_sigma_max - image_noise_sigma_min)
+ + image_noise_sigma_min
+ )
+ image_latents = (
+ image_noise_sigma * randn_tensor(image_latents.shape, generator=generator, device=device)
+ + (1 - image_noise_sigma) * image_latents
+ )
+ fake_image_noise_sigma = (
+ torch.rand(1, device=device, generator=generator) * (video_noise_sigma_max - video_noise_sigma_min)
+ + video_noise_sigma_min
+ )
+ fake_image_latents = (
+ fake_image_noise_sigma * randn_tensor(fake_image_latents.shape, generator=generator, device=device)
+ + (1 - fake_image_noise_sigma) * fake_image_latents
+ )
+
+ video_latents = None
+ if video is not None:
+ image_latents, video_latents = self.prepare_video_latents(
+ video,
+ latents_mean=latents_mean,
+ latents_std=latents_std,
+ num_latent_frames_per_chunk=num_latent_frames_per_chunk,
+ dtype=torch.float32,
+ device=device,
+ generator=generator,
+ )
+
+ if video_latents is not None and add_noise_to_video_latents:
+ image_noise_sigma = (
+ torch.rand(1, device=device, generator=generator) * (image_noise_sigma_max - image_noise_sigma_min)
+ + image_noise_sigma_min
+ )
+ image_latents = (
+ image_noise_sigma * randn_tensor(image_latents.shape, generator=generator, device=device)
+ + (1 - image_noise_sigma) * image_latents
+ )
+ noisy_latents_chunks = []
+ num_latent_chunks = video_latents.shape[2] // num_latent_frames_per_chunk
+ for i in range(num_latent_chunks):
+ chunk_start = i * num_latent_frames_per_chunk
+ chunk_end = chunk_start + num_latent_frames_per_chunk
+ latent_chunk = video_latents[:, :, chunk_start:chunk_end, :, :]
+ chunk_frames = latent_chunk.shape[2]
+ frame_sigmas = (
+ torch.rand(chunk_frames, device=device, generator=generator)
+ * (video_noise_sigma_max - video_noise_sigma_min)
+ + video_noise_sigma_min
+ )
+ frame_sigmas = frame_sigmas.view(1, 1, chunk_frames, 1, 1)
+ noisy_chunk = (
+ frame_sigmas * randn_tensor(latent_chunk.shape, generator=generator, device=device)
+ + (1 - frame_sigmas) * latent_chunk
+ )
+ noisy_latents_chunks.append(noisy_chunk)
+ video_latents = torch.cat(noisy_latents_chunks, dim=2)
+
+ num_channels_latents = self.transformer.config.in_channels
+ window_num_frames = (num_latent_frames_per_chunk - 1) * self.vae_scale_factor_temporal + 1
+ num_latent_chunk = max(1, (num_frames + window_num_frames - 1) // window_num_frames)
+ num_history_latent_frames = sum(history_sizes)
+ total_generated_latent_frames = 0
+ if not keep_first_frame:
+ history_sizes[-1] = history_sizes[-1] + 1
+
+ history_latents = torch.zeros(
+ batch_size,
+ num_channels_latents,
+ num_history_latent_frames,
+ height // self.vae_scale_factor_spatial,
+ width // self.vae_scale_factor_spatial,
+ device=device,
+ dtype=torch.float32,
+ )
+ if fake_image_latents is not None:
+ history_latents = torch.cat([history_latents[:, :, :-1, :, :], fake_image_latents], dim=2)
+ total_generated_latent_frames += 1
+ if video_latents is not None:
+ history_frames = history_latents.shape[2]
+ video_frames = video_latents.shape[2]
+ if video_frames < history_frames:
+ keep_frames = history_frames - video_frames
+ history_latents = torch.cat([history_latents[:, :, :keep_frames, :, :], video_latents], dim=2)
+ else:
+ history_latents = video_latents
+ total_generated_latent_frames += video_latents.shape[2]
+
+ if keep_first_frame:
+ indices = torch.arange(0, sum([1, *history_sizes, num_latent_frames_per_chunk]))
+ (
+ indices_prefix,
+ indices_latents_history_long,
+ indices_latents_history_mid,
+ indices_latents_history_1x,
+ indices_hidden_states,
+ ) = indices.split([1, *history_sizes, num_latent_frames_per_chunk], dim=0)
+ indices_latents_history_short = torch.cat([indices_prefix, indices_latents_history_1x], dim=0)
+ else:
+ indices = torch.arange(0, sum([*history_sizes, num_latent_frames_per_chunk]))
+ (
+ indices_latents_history_long,
+ indices_latents_history_mid,
+ indices_latents_history_short,
+ indices_hidden_states,
+ ) = indices.split([*history_sizes, num_latent_frames_per_chunk], dim=0)
+
+ state.prompt_embeds = prompt_embeds
+ state.negative_prompt_embeds = negative_prompt_embeds
+ state.do_true_cfg = self.do_classifier_free_guidance
+ state.chunk_index = 0
+ state.step_in_chunk = 0
+ state.total_chunks = num_latent_chunk
+ state.extra.update(
+ {
+ "attention_kwargs": extra.get("attention_kwargs", {}) or {},
+ "batch_size": batch_size,
+ "dtype": dtype,
+ "generator": generator,
+ "guidance_scale": guidance_scale,
+ "height": height,
+ "history_latents": history_latents,
+ "history_sizes": history_sizes,
+ "history_video": None,
+ "image_latents": image_latents,
+ "indices_hidden_states": indices_hidden_states.unsqueeze(0),
+ "indices_latents_history_long": indices_latents_history_long.unsqueeze(0),
+ "indices_latents_history_mid": indices_latents_history_mid.unsqueeze(0),
+ "indices_latents_history_short": indices_latents_history_short.unsqueeze(0),
+ "is_amplify_first_chunk": bool(extra.get("is_amplify_first_chunk", False)),
+ "is_enable_stage2": bool(extra.get("is_enable_stage2", False)),
+ "is_skip_first_chunk": bool(extra.get("is_skip_first_chunk", False)),
+ "keep_first_frame": keep_first_frame,
+ "latents_mean": latents_mean,
+ "latents_std": latents_std,
+ "num_channels_latents": num_channels_latents,
+ "num_history_latent_frames": num_history_latent_frames,
+ "num_latent_frames_per_chunk": num_latent_frames_per_chunk,
+ "num_steps": num_steps,
+ "output_type": output_type,
+ "pyramid_num_inference_steps_list": extra.get("pyramid_num_inference_steps_list", [10, 10, 10]),
+ "pyramid_num_stages": int(extra.get("pyramid_num_stages", 3)),
+ "total_generated_latent_frames": total_generated_latent_frames,
+ "use_cfg_zero_star": bool(extra.get("use_cfg_zero_star", False)),
+ "use_zero_init": bool(extra.get("use_zero_init", True)),
+ "vae_dtype": self.vae.dtype,
+ "width": width,
+ "window_num_frames": window_num_frames,
+ "zero_steps": int(extra.get("zero_steps", 1)),
+ }
+ )
+ self._prepare_next_chunk(state)
+ return state
+
+ def _prepare_next_chunk(self, state: DiffusionRequestState) -> None:
+ extra = state.extra
+ k = state.chunk_index
+ is_first_chunk = k == 0
+ is_second_chunk = k == 1
+ history_sizes = extra["history_sizes"]
+ history_latents = extra["history_latents"]
+ keep_first_frame = extra["keep_first_frame"]
+ image_latents = extra["image_latents"]
+ num_history_latent_frames = extra["num_history_latent_frames"]
+
+ if keep_first_frame:
+ latents_history_long, latents_history_mid, latents_history_1x = history_latents[
+ :, :, -num_history_latent_frames:
+ ].split(history_sizes, dim=2)
+ if image_latents is None and is_first_chunk:
+ latents_prefix = torch.zeros(
+ (
+ extra["batch_size"],
+ extra["num_channels_latents"],
+ 1,
+ latents_history_1x.shape[-2],
+ latents_history_1x.shape[-1],
+ ),
+ device=latents_history_1x.device,
+ dtype=latents_history_1x.dtype,
+ )
+ else:
+ latents_prefix = image_latents
+ latents_history_short = torch.cat([latents_prefix, latents_history_1x], dim=2)
+ else:
+ latents_history_long, latents_history_mid, latents_history_short = history_latents[
+ :, :, -num_history_latent_frames:
+ ].split(history_sizes, dim=2)
+
+ state.latents = self.prepare_latents(
+ extra["batch_size"],
+ extra["num_channels_latents"],
+ extra["height"],
+ extra["width"],
+ extra["window_num_frames"],
+ dtype=torch.float32,
+ device=self.device,
+ generator=extra["generator"],
+ latents=None,
+ )
+ extra["stage1_start_latents"] = state.latents
+ extra.update(
+ {
+ "is_first_chunk": is_first_chunk,
+ "is_second_chunk": is_second_chunk,
+ "latents_history_long": latents_history_long,
+ "latents_history_mid": latents_history_mid,
+ "latents_history_short": latents_history_short,
+ }
+ )
+
+ if not extra["is_enable_stage2"]:
+ patch_size = self.transformer.config.patch_size
+ image_seq_len = (state.latents.shape[-1] * state.latents.shape[-2] * state.latents.shape[-3]) // (
+ patch_size[0] * patch_size[1] * patch_size[2]
+ )
+ sigmas = self._stage1_sigmas(extra["num_steps"])
+ mu = calculate_shift(image_seq_len)
+ self.scheduler.set_timesteps(extra["num_steps"], device=self.device, sigmas=sigmas, mu=mu)
+ state.timesteps = self.scheduler.timesteps
+ state.chunk_num_steps = len(state.timesteps)
+ else:
+ self._prepare_stage2_chunk(state)
+
+ state.step_in_chunk = 0
+ state.step_index = 0
+ self._num_timesteps = state.chunk_num_steps
+
+ def _prepare_stage2_chunk(self, state: DiffusionRequestState) -> None:
+ extra = state.extra
+ batch_size, num_channel, num_frames_lat, height, width = state.latents.shape
+ latents_flat = state.latents.permute(0, 2, 1, 3, 4).reshape(
+ batch_size * num_frames_lat, num_channel, height, width
+ )
+ for _ in range(extra["pyramid_num_stages"] - 1):
+ height //= 2
+ width //= 2
+ latents_flat = F.interpolate(latents_flat, size=(height, width), mode="bilinear") * 2
+ state.latents = latents_flat.reshape(batch_size, num_frames_lat, num_channel, height, width).permute(
+ 0, 2, 1, 3, 4
+ )
+ extra["stage2_height"] = height
+ extra["stage2_width"] = width
+ extra["stage2_start_point_list"] = [state.latents] if self.is_distilled else None
+ extra["stage_index"] = 0
+ extra["stage_step_index"] = 0
+ amplify_first_chunk = extra["is_amplify_first_chunk"] and extra["is_first_chunk"]
+ state.chunk_num_steps = sum(
+ self._stage2_effective_num_steps(int(num_steps), amplify_first_chunk)
+ for num_steps in extra["pyramid_num_inference_steps_list"]
+ )
+ self._set_stage2_timesteps(state)
+
+ def _stage2_effective_num_steps(self, num_steps: int, is_amplify_first_chunk: bool) -> int:
+ if self.is_distilled and is_amplify_first_chunk:
+ return num_steps * 2
+ return num_steps
+
+ def _set_stage2_timesteps(self, state: DiffusionRequestState) -> None:
+ extra = state.extra
+ patch_size = self.transformer.config.patch_size
+ image_seq_len = (state.latents.shape[-1] * state.latents.shape[-2] * state.latents.shape[-3]) // (
+ patch_size[0] * patch_size[1] * patch_size[2]
+ )
+ mu = calculate_shift(image_seq_len)
+ stage_index = extra["stage_index"]
+ self.scheduler.set_timesteps(
+ extra["pyramid_num_inference_steps_list"][stage_index],
+ stage_index=stage_index,
+ device=self.device,
+ mu=mu,
+ is_amplify_first_chunk=extra["is_amplify_first_chunk"] and extra["is_first_chunk"],
+ )
+ state.timesteps = self.scheduler.timesteps
+ state.step_index = extra["stage_step_index"]
+
+ def denoise_step(
+ self,
+ input_batch: InputBatch,
+ states: Sequence[DiffusionRequestState],
+ **kwargs: Any,
+ ) -> torch.Tensor | None:
+ del kwargs
+ if len(states) != 1:
+ raise ValueError("Helios step execution supports a single request, not a batched request.")
+ state = states[0]
+ if state.extra["is_enable_stage2"]:
+ return self._denoise_stage2_step(state)
+ return self._denoise_stage1_step(state, input_batch.latents, input_batch.timesteps)
+
+ def _denoise_stage1_step(
+ self,
+ state: DiffusionRequestState,
+ latents: torch.Tensor,
+ timesteps: torch.Tensor,
+ ) -> torch.Tensor:
+ extra = state.extra
+ batch_size = latents.shape[0]
+ t = timesteps
+ self._current_timestep = t
+ timestep = t.expand(batch_size)
+ transformer_kwargs = {
+ "hidden_states": latents.to(extra["dtype"]),
+ "timestep": timestep,
+ "indices_hidden_states": extra["indices_hidden_states"],
+ "indices_latents_history_short": extra["indices_latents_history_short"],
+ "indices_latents_history_mid": extra["indices_latents_history_mid"],
+ "indices_latents_history_long": extra["indices_latents_history_long"],
+ "latents_history_short": extra["latents_history_short"].to(extra["dtype"]),
+ "latents_history_mid": extra["latents_history_mid"].to(extra["dtype"]),
+ "latents_history_long": extra["latents_history_long"].to(extra["dtype"]),
+ "attention_kwargs": extra["attention_kwargs"],
+ "return_dict": False,
+ }
+ if extra["use_cfg_zero_star"] and state.do_true_cfg:
+ noise_pred = self.transformer(
+ encoder_hidden_states=state.prompt_embeds,
+ **transformer_kwargs,
+ )[0]
+ noise_uncond = self.transformer(
+ encoder_hidden_states=state.negative_prompt_embeds,
+ **transformer_kwargs,
+ )[0]
+ positive_flat = noise_pred.view(batch_size, -1)
+ negative_flat = noise_uncond.view(batch_size, -1)
+ alpha_cfg = optimized_scale(positive_flat, negative_flat)
+ alpha_cfg = alpha_cfg.view(batch_size, *([1] * (len(noise_pred.shape) - 1))).to(noise_pred.dtype)
+ if (state.step_in_chunk <= extra["zero_steps"]) and extra["use_zero_init"]:
+ return noise_pred * 0.0
+ return noise_uncond * alpha_cfg + extra["guidance_scale"] * (noise_pred - noise_uncond * alpha_cfg)
+
+ positive_kwargs = {
+ "encoder_hidden_states": state.prompt_embeds,
+ **transformer_kwargs,
+ }
+ negative_kwargs = (
+ {
+ "encoder_hidden_states": state.negative_prompt_embeds,
+ **transformer_kwargs,
+ }
+ if state.do_true_cfg
+ else None
+ )
+ return self.predict_noise_maybe_with_cfg(
+ do_true_cfg=state.do_true_cfg,
+ true_cfg_scale=extra["guidance_scale"],
+ positive_kwargs=positive_kwargs,
+ negative_kwargs=negative_kwargs,
+ cfg_normalize=False,
+ )
+
+ def _denoise_stage2_step(self, state: DiffusionRequestState) -> torch.Tensor:
+ extra = state.extra
+ latents = state.latents
+ assert latents is not None
+ batch_size = latents.shape[0]
+ t = state.current_timestep
+ assert t is not None
+ self._current_timestep = t
+ timestep = t.expand(batch_size).to(torch.int64)
+ transformer_kwargs = {
+ "hidden_states": latents.to(extra["dtype"]),
+ "timestep": timestep,
+ "indices_hidden_states": extra["indices_hidden_states"],
+ "indices_latents_history_short": extra["indices_latents_history_short"],
+ "indices_latents_history_mid": extra["indices_latents_history_mid"],
+ "indices_latents_history_long": extra["indices_latents_history_long"],
+ "latents_history_short": extra["latents_history_short"].to(extra["dtype"]),
+ "latents_history_mid": extra["latents_history_mid"].to(extra["dtype"]),
+ "latents_history_long": extra["latents_history_long"].to(extra["dtype"]),
+ "attention_kwargs": extra["attention_kwargs"],
+ "return_dict": False,
+ }
+ noise_pred = self.transformer(encoder_hidden_states=state.prompt_embeds, **transformer_kwargs)[0]
+ if state.do_true_cfg:
+ noise_uncond = self.transformer(encoder_hidden_states=state.negative_prompt_embeds, **transformer_kwargs)[0]
+ if extra["use_cfg_zero_star"]:
+ positive_flat = noise_pred.view(batch_size, -1)
+ negative_flat = noise_uncond.view(batch_size, -1)
+ alpha_cfg = optimized_scale(positive_flat, negative_flat)
+ alpha_cfg = alpha_cfg.view(batch_size, *([1] * (len(noise_pred.shape) - 1))).to(noise_pred.dtype)
+ if (
+ extra["stage_index"] == 0
+ and extra["stage_step_index"] <= extra["zero_steps"]
+ and extra["use_zero_init"]
+ ):
+ noise_pred = noise_pred * 0.0
+ else:
+ noise_pred = noise_uncond * alpha_cfg + extra["guidance_scale"] * (
+ noise_pred - noise_uncond * alpha_cfg
+ )
+ else:
+ noise_pred = noise_uncond + extra["guidance_scale"] * (noise_pred - noise_uncond)
+ return noise_pred
+
+ def step_scheduler(
+ self,
+ state: DiffusionRequestState,
+ noise_pred: torch.Tensor,
+ **kwargs: Any,
+ ) -> None:
+ del kwargs
+ if state.extra["is_enable_stage2"]:
+ self._step_scheduler_stage2(state, noise_pred)
+ else:
+ t = state.current_timestep
+ assert t is not None
+ if self.is_distilled:
+ state.latents = self.scheduler.step(
+ noise_pred,
+ t,
+ state.latents,
+ return_dict=False,
+ cur_sampling_step=state.step_in_chunk,
+ dmd_noisy_tensor=state.extra["stage1_start_latents"],
+ dmd_sigmas=self.scheduler.sigmas,
+ dmd_timesteps=self.scheduler.timesteps,
+ all_timesteps=state.timesteps,
+ )[0]
+ else:
+ state.latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, state.latents, state.do_true_cfg)
+ state.step_in_chunk += 1
+ state.step_index = state.step_in_chunk
+
+ def _step_scheduler_stage2(self, state: DiffusionRequestState, noise_pred: torch.Tensor) -> None:
+ extra = state.extra
+ t = state.current_timestep
+ assert t is not None and state.latents is not None
+ start_points = extra["stage2_start_point_list"]
+ state.latents = self.scheduler.step(
+ noise_pred,
+ t,
+ state.latents,
+ return_dict=False,
+ cur_sampling_step=extra["stage_step_index"],
+ dmd_noisy_tensor=start_points[extra["stage_index"]] if start_points is not None else None,
+ dmd_sigmas=self.scheduler.sigmas,
+ dmd_timesteps=self.scheduler.timesteps,
+ all_timesteps=state.timesteps,
+ )[0]
+ extra["stage_step_index"] += 1
+ state.step_in_chunk += 1
+ state.step_index = extra["stage_step_index"]
+
+ if extra["stage_step_index"] < len(state.timesteps):
+ return
+ if extra["stage_index"] >= extra["pyramid_num_stages"] - 1:
+ return
+
+ extra["stage_index"] += 1
+ extra["stage_step_index"] = 0
+ extra["stage2_height"] *= 2
+ extra["stage2_width"] *= 2
+ batch_size, num_channel, num_frames_cur = state.latents.shape[:3]
+ latents_flat = state.latents.permute(0, 2, 1, 3, 4).reshape(
+ batch_size * num_frames_cur,
+ num_channel,
+ extra["stage2_height"] // 2,
+ extra["stage2_width"] // 2,
+ )
+ latents_flat = F.interpolate(latents_flat, size=(extra["stage2_height"], extra["stage2_width"]), mode="nearest")
+ state.latents = latents_flat.reshape(
+ batch_size,
+ num_frames_cur,
+ num_channel,
+ extra["stage2_height"],
+ extra["stage2_width"],
+ ).permute(0, 2, 1, 3, 4)
+
+ ori_sigma = 1 - self.scheduler.ori_start_sigmas[extra["stage_index"]]
+ gamma = self.scheduler.config.gamma
+ alpha = 1 / (math.sqrt(1 + (1 / gamma)) * (1 - ori_sigma) + ori_sigma)
+ beta = alpha * (1 - ori_sigma) / math.sqrt(gamma)
+ patch_size = self.transformer.config.patch_size
+ noise = self.sample_block_noise(
+ batch_size,
+ num_channel,
+ state.latents.shape[2],
+ extra["stage2_height"],
+ extra["stage2_width"],
+ patch_size,
+ generator=extra["generator"],
+ ).to(device=self.device, dtype=extra["dtype"])
+ state.latents = alpha * state.latents + beta * noise
+ if self.is_distilled and extra["stage2_start_point_list"] is not None:
+ extra["stage2_start_point_list"].append(state.latents)
+ self._set_stage2_timesteps(state)
+
+ def post_decode(
+ self,
+ state: DiffusionRequestState,
+ **kwargs: Any,
+ ) -> DiffusionOutput:
+ del kwargs
+ extra = state.extra
+ is_first_chunk = extra["is_first_chunk"]
+ is_second_chunk = extra["is_second_chunk"]
+ if extra["keep_first_frame"] and (
+ (is_first_chunk and extra["image_latents"] is None) or (extra["is_skip_first_chunk"] and is_second_chunk)
+ ):
+ extra["image_latents"] = state.latents[:, :, 0:1, :, :]
+
+ extra["total_generated_latent_frames"] += state.latents.shape[2]
+ extra["history_latents"] = torch.cat([extra["history_latents"], state.latents], dim=2)
+ real_history_latents = extra["history_latents"][:, :, -extra["total_generated_latent_frames"] :]
+ current_latents = (
+ real_history_latents[:, :, -extra["num_latent_frames_per_chunk"] :].to(extra["vae_dtype"])
+ / extra["latents_std"]
+ + extra["latents_mean"]
+ )
+ current_video = self.vae.decode(current_latents, return_dict=False)[0]
+ if extra["history_video"] is None:
+ extra["history_video"] = current_video
+ else:
+ extra["history_video"] = torch.cat([extra["history_video"], current_video], dim=2)
+
+ output = current_latents if extra["output_type"] == "latent" else current_video
+ completed_chunk_index = state.chunk_index
+ state.chunk_index += 1
+ finished = state.request_denoise_completed
+ if not finished:
+ self._prepare_next_chunk(state)
+ else:
+ self._current_timestep = None
+ if current_omni_platform.is_available():
+ current_omni_platform.empty_cache()
+
+ return DiffusionOutput(
+ output=output,
+ stage_durations=self.stage_durations if hasattr(self, "stage_durations") else {},
+ chunk_index=completed_chunk_index,
+ total_chunks=state.total_chunks,
+ finished=finished,
+ )
+
def forward(
self,
req: OmniDiffusionRequest,
diff --git a/vllm_omni/diffusion/models/interface.py b/vllm_omni/diffusion/models/interface.py
index c9d013ff3..657a45bf8 100644
--- a/vllm_omni/diffusion/models/interface.py
+++ b/vllm_omni/diffusion/models/interface.py
@@ -56,15 +56,19 @@ class SupportsStepExecution(Protocol):
def prepare_encode(self, state: DiffusionRequestState, **kwargs: Any) -> DiffusionRequestState:
"""Prepare request-level inputs and return initialized state."""
+ ...
def denoise_step(self, input_batch: InputBatch, **kwargs: Any) -> torch.Tensor | None:
"""Run one denoise forward on the runner-assembled batch."""
+ ...
def step_scheduler(self, state: DiffusionRequestState, noise_pred: torch.Tensor, **kwargs: Any) -> None:
"""Run one scheduler step."""
+ ...
def post_decode(self, state: DiffusionRequestState, **kwargs: Any) -> DiffusionOutput:
- """Decode output after denoise loop."""
+ """Decode output after denoise loop or at a partial chunk boundary."""
+ ...
@runtime_checkable
diff --git a/vllm_omni/diffusion/output_formatter.py b/vllm_omni/diffusion/output_formatter.py
index 8d00b8f85..26cfa992e 100644
--- a/vllm_omni/diffusion/output_formatter.py
+++ b/vllm_omni/diffusion/output_formatter.py
@@ -70,7 +70,11 @@ def normalize_diffusion_postprocess_output(
)
-def format_empty_diffusion_outputs(request: OmniDiffusionRequest) -> list[OmniRequestOutput]:
+def format_empty_diffusion_outputs(
+ request: OmniDiffusionRequest,
+ *,
+ finished: bool = True,
+) -> list[OmniRequestOutput]:
return [
OmniRequestOutput.from_diffusion(
request_id=request.request_id,
@@ -78,6 +82,7 @@ def format_empty_diffusion_outputs(request: OmniDiffusionRequest) -> list[OmniRe
prompt=prompt,
metrics={},
latents=None,
+ finished=finished,
)
for prompt in request.prompts
]
@@ -125,6 +130,7 @@ def format_diffusion_outputs(
is_text_output=is_text_output,
is_audio_output=is_audio_output,
audio_sample_rate=audio_sample_rate,
+ finished=diffusion_output.finished,
)
return _format_multi_prompt_outputs(
@@ -135,6 +141,7 @@ def format_diffusion_outputs(
postprocess_output=postprocess_output,
is_audio_output=is_audio_output,
audio_sample_rate=audio_sample_rate,
+ finished=diffusion_output.finished,
)
@@ -185,6 +192,7 @@ def _format_single_prompt_output(
is_text_output: bool,
is_audio_output: bool,
audio_sample_rate: int | None,
+ finished: bool = True,
) -> list[OmniRequestOutput]:
prompt = request.prompts[0]
request_id = request.request_id
@@ -202,6 +210,7 @@ def _format_single_prompt_output(
final_output_type="text",
stage_durations=diffusion_output.stage_durations,
peak_memory_mb=diffusion_output.peak_memory_mb,
+ finished=finished,
),
]
@@ -227,6 +236,7 @@ def _format_single_prompt_output(
final_output_type="audio",
stage_durations=diffusion_output.stage_durations,
peak_memory_mb=diffusion_output.peak_memory_mb,
+ finished=finished,
),
]
@@ -245,6 +255,7 @@ def _format_single_prompt_output(
multimodal_output=mm_output,
stage_durations=diffusion_output.stage_durations,
peak_memory_mb=diffusion_output.peak_memory_mb,
+ finished=finished,
),
]
@@ -258,6 +269,7 @@ def _format_multi_prompt_outputs(
postprocess_output: DiffusionPostprocessOutput,
is_audio_output: bool,
audio_sample_rate: int | None,
+ finished: bool = True,
) -> list[OmniRequestOutput]:
results = []
output_idx = 0
@@ -298,6 +310,7 @@ def _format_multi_prompt_outputs(
final_output_type="audio",
stage_durations=diffusion_output.stage_durations,
peak_memory_mb=diffusion_output.peak_memory_mb,
+ finished=finished,
),
)
continue
@@ -337,6 +350,7 @@ def _format_multi_prompt_outputs(
multimodal_output=mm_output,
stage_durations=diffusion_output.stage_durations,
peak_memory_mb=diffusion_output.peak_memory_mb,
+ finished=finished,
),
)
diff --git a/vllm_omni/diffusion/profiler/diffusion_pipeline_profiler.py b/vllm_omni/diffusion/profiler/diffusion_pipeline_profiler.py
index f67e9f692..8d4bf41de 100644
--- a/vllm_omni/diffusion/profiler/diffusion_pipeline_profiler.py
+++ b/vllm_omni/diffusion/profiler/diffusion_pipeline_profiler.py
@@ -81,7 +81,9 @@ class DiffusionPipelineProfilerMixin:
_PROFILER_TARGETS = ["vae.encode", "vae.decode", "diffuse", "text_encoder.forward", "tokenizer.forward"]
def setup_diffusion_pipeline_profiler(
- self, profiler_targets: list[str] | None = None, enable_diffusion_pipeline_profiler: bool = False
+ self,
+ profiler_targets: list[str] | None = None,
+ enable_diffusion_pipeline_profiler: bool = False,
) -> None:
self.enable_diffusion_pipeline_profiler = enable_diffusion_pipeline_profiler
if not enable_diffusion_pipeline_profiler:
diff --git a/vllm_omni/diffusion/stage_diffusion_proc.py b/vllm_omni/diffusion/stage_diffusion_proc.py
index 7530e61c9..aa058c8d0 100644
--- a/vllm_omni/diffusion/stage_diffusion_proc.py
+++ b/vllm_omni/diffusion/stage_diffusion_proc.py
@@ -10,6 +10,7 @@ import asyncio
import contextlib
import multiprocessing.connection
import signal
+from collections.abc import AsyncGenerator
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
@@ -172,6 +173,29 @@ class StageDiffusionProc:
result.request_id = request_id
return result
+ async def _process_streaming_request(
+ self,
+ request_id: str,
+ prompt: Any,
+ sampling_params_dict: dict,
+ kv_sender_info: dict[str, Any] | None = None,
+ ) -> AsyncGenerator[OmniRequestOutput, None]:
+ """Process a streaming diffusion request and yield the results from DiffusionEngine.step_streaming()."""
+ sampling_params = self._reconstruct_sampling_params(sampling_params_dict)
+
+ request = OmniDiffusionRequest(
+ prompts=[prompt],
+ sampling_params=sampling_params,
+ request_id=request_id,
+ kv_sender_info=kv_sender_info,
+ )
+
+ async for results in self._engine.step_streaming(request): # pyright: ignore[reportOptionalMemberAccess]
+ result = results[0]
+ if not result.request_id:
+ result.request_id = request_id
+ yield result
+
async def _process_batch_request(
self,
request_id: str,
@@ -186,6 +210,9 @@ class StageDiffusionProc:
``images`` list contains every generated image, matching the
contract expected by the orchestrator and tests.
"""
+ if self._od_config.streaming_output:
+ raise NotImplementedError("Streaming output is not supported for batched requests")
+
sampling_params = self._reconstruct_sampling_params(sampling_params_dict)
request = OmniDiffusionRequest(
@@ -388,13 +415,22 @@ class StageDiffusionProc:
) -> None:
"""Process a single diffusion request and send the response."""
try:
- result = await self._process_request(
- request_id,
- prompt,
- sampling_params_dict,
- kv_sender_info=kv_sender_info,
- )
- await response_socket.send(encoder.encode({"type": "result", "output": result}))
+ if not self._od_config.streaming_output:
+ result = await self._process_request(
+ request_id,
+ prompt,
+ sampling_params_dict,
+ kv_sender_info=kv_sender_info,
+ )
+ await response_socket.send(encoder.encode({"type": "result", "output": result}))
+ else:
+ async for result in self._process_streaming_request(
+ request_id,
+ prompt,
+ sampling_params_dict,
+ kv_sender_info=kv_sender_info,
+ ):
+ await response_socket.send(encoder.encode({"type": "result", "output": result}))
except DiffusionRequestAbortedError as e:
logger.info(
"request_id: %s aborted: %s",
diff --git a/vllm_omni/diffusion/utils/media_utils.py b/vllm_omni/diffusion/utils/media_utils.py
index a09cd4595..314a9d2bf 100644
--- a/vllm_omni/diffusion/utils/media_utils.py
+++ b/vllm_omni/diffusion/utils/media_utils.py
@@ -6,11 +6,124 @@ from __future__ import annotations
import io
from fractions import Fraction
+from typing import Any, cast
import av
import numpy as np
+class FragmentedMP4Muxer:
+ """Incrementally mux video frames into one fragmented MP4 byte stream."""
+
+ def __init__(
+ self,
+ *,
+ width: int,
+ height: int,
+ fps: float = 25.0,
+ video_codec: str = "h264",
+ crf: str = "18",
+ video_codec_options: dict[str, str] | None = None,
+ ) -> None:
+ self._buf = io.BytesIO()
+ self._closed = False
+ self._container = av.open(
+ self._buf,
+ mode="w",
+ format="mp4",
+ options={"movflags": "+frag_every_frame+empty_moov+default_base_moof"},
+ )
+
+ self._stream: av.VideoStream = cast(
+ av.VideoStream,
+ self._container.add_stream(video_codec, rate=Fraction(fps).limit_denominator(10000)),
+ )
+ self._stream.width = width
+ self._stream.height = height
+ self._stream.pix_fmt = "yuv420p"
+
+ options: dict[str, object] = {"crf": str(crf)}
+ if video_codec_options:
+ options.update(video_codec_options)
+ self._stream.options = options
+
+ try:
+ self._stream.codec_context.max_b_frames = 0
+ except AttributeError:
+ pass
+
+ def mux_video_frames(self, video_frames: np.ndarray) -> bytes:
+ """Mux a batch of ``uint8`` RGB frames and return newly written MP4 bytes."""
+ if self._closed:
+ raise RuntimeError("Cannot mux frames after FragmentedMP4Muxer.close().")
+ if video_frames.ndim != 4 or video_frames.shape[-1] != 3:
+ raise ValueError("video_frames must have shape (T, H, W, 3).")
+ if video_frames.dtype != np.uint8:
+ raise ValueError("video_frames must be uint8.")
+ if video_frames.shape[1] != self._stream.height or video_frames.shape[2] != self._stream.width:
+ raise ValueError("All fragmented MP4 chunks in a session must use the same frame size.")
+
+ for frame_data in video_frames:
+ frame = av.VideoFrame.from_ndarray(frame_data, format="rgb24")
+ for packet in self._stream.encode(frame):
+ self._container.mux(packet)
+ return self._read_new_bytes()
+
+ def close(self) -> bytes:
+ """Flush delayed encoder packets, close the container, and return final bytes."""
+ if self._closed:
+ return b""
+ for packet in self._stream.encode():
+ self._container.mux(packet)
+ self._container.close()
+ self._closed = True
+ return self._read_new_bytes()
+
+ def _read_new_bytes(self) -> bytes:
+ """Return newly muxed bytes in the current video container,
+ then clear the buffer to prepare for the next chunk."""
+ chunk = self._buf.getvalue()
+ self._buf.seek(0)
+ self._buf.truncate()
+ return chunk
+
+
+def finalize_streaming_video_bytes(
+ video_bytes: bytes,
+ *,
+ input_format: str,
+ fps: float = 25.0,
+ video_codec_options: dict[str, str] | None = None,
+) -> bytes:
+ """Convert streamed video bytes into a progressive MP4 for local playback."""
+ if not video_bytes:
+ return video_bytes
+
+ normalized_format = input_format.lower()
+ if normalized_format == "m4s":
+ demux_format = "mp4"
+ else:
+ raise ValueError(f"Unsupported streaming video format: {input_format}")
+
+ try:
+ with cast(Any, av.open(io.BytesIO(video_bytes), format=demux_format)) as container:
+ stream = container.streams.video[0]
+ frame_arrays = [frame.to_ndarray(format="rgb24") for frame in container.decode(stream)]
+ except Exception:
+ return video_bytes
+
+ if not frame_arrays:
+ return video_bytes
+
+ frames_u8 = np.ascontiguousarray(np.stack(frame_arrays, axis=0), dtype=np.uint8)
+ return mux_video_audio_bytes(
+ frames_u8,
+ None,
+ fps=float(fps),
+ video_codec_options=video_codec_options,
+ )
+
+
def mux_video_audio_bytes(
video_frames: np.ndarray,
audio_waveform: np.ndarray | None = None,
@@ -39,17 +152,19 @@ def mux_video_audio_bytes(
buf = io.BytesIO()
container = av.open(buf, mode="w", format="mp4")
- v_stream = container.add_stream(video_codec, rate=Fraction(fps).limit_denominator(10000))
+ v_stream = cast(av.VideoStream, container.add_stream(video_codec, rate=Fraction(fps).limit_denominator(10000)))
v_stream.width = video_frames.shape[2]
v_stream.height = video_frames.shape[1]
v_stream.pix_fmt = "yuv420p"
- options = {"crf": str(crf)}
+ options: dict[str, object] = {"crf": str(crf)}
if video_codec_options:
options.update(video_codec_options)
v_stream.options = options
- a_stream = None
+ a_stream: av.AudioStream | None = None
+ samples: np.ndarray | None = None
+ layout: str | None = None
if audio_waveform is not None:
samples = audio_waveform.astype(np.float32)
if samples.ndim == 1:
@@ -58,7 +173,7 @@ def mux_video_audio_bytes(
samples = np.ascontiguousarray(samples.T)
num_channels = samples.shape[0]
layout = "stereo" if num_channels >= 2 else "mono"
- a_stream = container.add_stream(audio_codec, rate=audio_sample_rate)
+ a_stream = cast(av.AudioStream, container.add_stream(audio_codec, rate=audio_sample_rate))
a_stream.layout = layout
for frame_data in video_frames:
@@ -69,6 +184,8 @@ def mux_video_audio_bytes(
container.mux(packet)
if a_stream is not None and audio_waveform is not None:
+ if samples is None or layout is None:
+ raise ValueError("Audio samples were not prepared for muxing.")
audio_frame = av.AudioFrame.from_ndarray(samples, format="fltp", layout=layout)
audio_frame.sample_rate = audio_sample_rate
for packet in a_stream.encode(audio_frame):
diff --git a/vllm_omni/diffusion/worker/diffusion_model_runner.py b/vllm_omni/diffusion/worker/diffusion_model_runner.py
index 288973637..9f3598abe 100644
--- a/vllm_omni/diffusion/worker/diffusion_model_runner.py
+++ b/vllm_omni/diffusion/worker/diffusion_model_runner.py
@@ -152,12 +152,21 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
)
logger.info("Model runner: Model loaded successfully.")
+ if self.od_config.streaming_output and not getattr(self.od_config, "step_execution", False):
+ logger.warning("streaming_output=True requires step_execution=True; enabling step execution.")
+ self.od_config.step_execution = True
+
if getattr(self.od_config, "step_execution", False) and not self.supports_step_mode():
raise ValueError(
"step_execution=True requires a pipeline implementing "
"prepare_encode(), denoise_step(), step_scheduler(), and post_decode(); "
f"{self.od_config.model_class_name} does not support that contract."
)
+ if self.od_config.streaming_output and not self.supports_step_mode():
+ raise ValueError(
+ "streaming_output=True requires step execution support; "
+ f"{self.od_config.model_class_name} does not support that contract."
+ )
# Apply CPU offloading
self.offload_backend = get_offload_backend(self.od_config, device=self.device)
@@ -357,7 +366,6 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
and self.od_config.enable_cache_dit_summary
):
cache_summary(self.pipeline, details=True)
-
return output
# ------------------------------------------------------------------
@@ -457,7 +465,7 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
scatter_latents(states, input_batch)
for state in states:
- if interrupted or state.denoise_completed:
+ if interrupted or state.request_denoise_completed:
self.state_cache.pop(state.request_id, None)
def _prepare_attn_metadata(self, input_batch: InputBatch) -> Any:
@@ -492,7 +500,7 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
omni_diffusion_config=self.od_config,
attn_metadata=attn_metadata,
):
- noise_pred = self.pipeline.denoise_step(input_batch)
+ noise_pred = self.pipeline.denoise_step(input_batch, states=states)
runner_output_list = []
pipeline_interrupted = getattr(self.pipeline, "interrupt", False)
@@ -515,15 +523,24 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
req, noise_pred[offset : offset + row_num] if noise_pred is not None else None
)
offset = offset + row_num
- if req.denoise_completed:
+ if self.od_config.streaming_output:
+ should_decode = req.chunk_denoise_completed
+ else:
+ should_decode = req.denoise_completed
+
+ if should_decode:
result = self.pipeline.post_decode(req)
else:
result = None
+ # finished should be computed after post_decode() advanced chunk_index
+ finished = (
+ req.request_denoise_completed if self.od_config.streaming_output else req.denoise_completed
+ )
runner_output_list.append(
RunnerOutput(
request_id=req.request_id,
step_index=req.step_index,
- finished=req.denoise_completed,
+ finished=finished,
result=result,
)
)
diff --git a/vllm_omni/diffusion/worker/utils.py b/vllm_omni/diffusion/worker/utils.py
index a0bfcea9b..02cb0b14a 100644
--- a/vllm_omni/diffusion/worker/utils.py
+++ b/vllm_omni/diffusion/worker/utils.py
@@ -55,6 +55,12 @@ class DiffusionRequestState:
timesteps: torch.Tensor | list[torch.Tensor] | None = None
step_index: int = 0
+ # ── Optional chunked streaming progress ──
+ chunk_index: int = 0
+ step_in_chunk: int = 0
+ total_chunks: int = 1
+ chunk_num_steps: int | None = None
+
# ── Per-request scheduler instance (set once by prepare_encode) ──
scheduler: Any | None = None
@@ -103,6 +109,18 @@ class DiffusionRequestState:
return False
return self.step_index >= total_steps
+ @property
+ def chunk_denoise_completed(self) -> bool:
+ if self.chunk_num_steps is None:
+ return False
+ return self.step_in_chunk >= self.chunk_num_steps
+
+ @property
+ def request_denoise_completed(self) -> bool:
+ if self.chunk_num_steps is None:
+ return self.denoise_completed
+ return self.chunk_index >= self.total_chunks
+
@property
def new_request(self) -> bool:
# TODO: this is only an approximation for current stepwise mode.
diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py
index ac6140291..3cee3ccd8 100644
--- a/vllm_omni/engine/async_omni_engine.py
+++ b/vllm_omni/engine/async_omni_engine.py
@@ -1027,6 +1027,7 @@ class AsyncOmniEngine:
**({"diffusion_attention_config": attention_config} if attention_config is not None else {}),
"force_cutlass_fp8": bool(kwargs.get("force_cutlass_fp8", False)),
"enable_diffusion_pipeline_profiler": kwargs.get("enable_diffusion_pipeline_profiler", False),
+ "streaming_output": kwargs.get("diffusion_streaming_output", False),
"enable_ar_profiler": kwargs.get("enable_ar_profiler", False),
"extras": {
"auxiliary_text_encoder": kwargs.get("auxiliary_text_encoder", None),
diff --git a/vllm_omni/entrypoints/cli/serve.py b/vllm_omni/entrypoints/cli/serve.py
index e45681b5c..3902b11ba 100644
--- a/vllm_omni/entrypoints/cli/serve.py
+++ b/vllm_omni/entrypoints/cli/serve.py
@@ -662,6 +662,14 @@ class OmniServeCommand(CLISubcommand):
type=int,
help="Maximum generated image size in pixels (height * width).",
)
+ # Diffusion model (mainly video generation models) streaming output mode
+ omni_config_group.add_argument(
+ "--diffusion-streaming-output",
+ dest="diffusion_streaming_output",
+ action="store_true",
+ default=False,
+ help="Enable chunked streaming output for diffusion (mainly video generation) models that support it.",
+ )
# TTS-specific parameters
omni_config_group.add_argument(
diff --git a/vllm_omni/entrypoints/omni_base.py b/vllm_omni/entrypoints/omni_base.py
index fc22493fc..c9f6a1d93 100644
--- a/vllm_omni/entrypoints/omni_base.py
+++ b/vllm_omni/entrypoints/omni_base.py
@@ -603,6 +603,7 @@ class OmniBase(PDDisaggregationMixin):
metrics=response_metrics,
stage_durations=stage_durations,
peak_memory_mb=peak_memory_mb,
+ finished=finished,
)
def shutdown(self, timeout: float | None = None) -> None:
diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py
index 7de919fc8..405803d9c 100755
--- a/vllm_omni/entrypoints/openai/api_server.py
+++ b/vllm_omni/entrypoints/openai/api_server.py
@@ -132,6 +132,7 @@ from vllm_omni.entrypoints.openai.serving_video import (
ReferenceImage,
ReferenceVideo,
)
+from vllm_omni.entrypoints.openai.serving_video_output_stream import OmniStreamingVideoOutputHandler
from vllm_omni.entrypoints.openai.serving_video_stream import create_streaming_video_handler
from vllm_omni.entrypoints.openai.stage_params import (
build_stage_sampling_params_list,
@@ -774,6 +775,11 @@ async def omni_init_app_state(
model_name=model_name,
stage_configs=diffusion_stage_configs,
)
+ state.openai_streaming_video_output = OmniStreamingVideoOutputHandler(
+ engine_client=engine_client,
+ model_name=model_name,
+ stage_configs=diffusion_stage_configs,
+ )
state.openai_serving_speech = OmniOpenAIServingSpeech.for_diffusion(
diffusion_engine=engine_client,
@@ -1594,6 +1600,23 @@ async def streaming_video_chat(websocket: WebSocket):
await handler.handle_session(websocket)
+@router.websocket("/v1/realtime/video")
+async def streaming_video_output(websocket: WebSocket):
+ """WebSocket endpoint for streaming generated video output chunks."""
+ handler = getattr(websocket.app.state, "openai_streaming_video_output", None)
+ if handler is None:
+ await websocket.accept()
+ await websocket.send_json(
+ {
+ "type": "error",
+ "message": "Streaming video generation is not available",
+ }
+ )
+ await websocket.close()
+ return
+ await handler.handle_session(websocket)
+
+
@router.websocket("/v1/realtime")
async def realtime_websocket(websocket: WebSocket):
"""WebSocket endpoint for OpenAI-style realtime interactions."""
diff --git a/vllm_omni/entrypoints/openai/serving_video_output_stream.py b/vllm_omni/entrypoints/openai/serving_video_output_stream.py
new file mode 100644
index 000000000..f713df07c
--- /dev/null
+++ b/vllm_omni/entrypoints/openai/serving_video_output_stream.py
@@ -0,0 +1,519 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""WebSocket handler for streaming generated video chunks.
+
+Protocol:
+ Client -> Server:
+ {"type": "session.start", "model": "...", "prompt": "...", "format": "m4s", ...}
+ {"type": "session.prompt_update", "prompt": "..."} # currently unsupported
+ {"type": "session.stop"}
+ {"type": "session.ping"} # optional; refreshes stall clock
+
+ Server -> Client:
+ {"type": "video.start", "request_id": "...", "config": {...}, "format": "m4s"}
+
+ {"type": "session.done", ...}
+ {"type": "session.pong"} # reply to session.ping
+ {"type": "error", "message": "..."}
+"""
+
+from __future__ import annotations
+
+import asyncio
+import copy
+import inspect
+import json
+import time
+import uuid
+from collections.abc import AsyncGenerator
+from http import HTTPStatus
+from typing import Any, cast
+
+from fastapi import HTTPException, WebSocket, WebSocketDisconnect
+from pydantic import ValidationError
+from vllm.engine.protocol import EngineClient
+from vllm.logger import init_logger
+
+from vllm_omni.entrypoints.openai.protocol.videos import VideoGenerationRequest, VideoParams
+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 get_stage_type, parse_lora_request
+from vllm_omni.entrypoints.openai.video_api_utils import StreamingVideoFormat, create_streaming_video_encoder
+from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt
+
+logger = init_logger(__name__)
+
+_DEFAULT_STALL_TIMEOUT = 60.0
+_DEFAULT_START_TIMEOUT = 10.0
+_CONTROL_POLL_INTERVAL = 1.0
+_MAX_START_MESSAGE_SIZE = 4 * 1024 * 1024
+_MAX_CONTROL_MESSAGE_SIZE = 128 * 1024
+
+
+class _SessionProgress:
+ """Tracks last server-side progress for stall-timeout enforcement."""
+
+ def __init__(self) -> None:
+ self._last_at = time.monotonic()
+
+ def touch(self) -> None:
+ self._last_at = time.monotonic()
+
+ def stalled_for(self, stall_timeout: float) -> bool:
+ return time.monotonic() - self._last_at > stall_timeout
+
+
+class OmniStreamingVideoOutputHandler:
+ """Handles `/v1/realtime/video` sessions for generated video output."""
+
+ def __init__(
+ self,
+ engine_client: EngineClient,
+ model_name: str | None = None,
+ stage_configs: list[Any] | None = None,
+ stall_timeout: float = _DEFAULT_STALL_TIMEOUT,
+ start_timeout: float = _DEFAULT_START_TIMEOUT,
+ ) -> None:
+ self._engine_client = engine_client
+ self._model_name = model_name
+ self._stage_configs = stage_configs
+ self._stall_timeout = stall_timeout
+ self._start_timeout = start_timeout
+
+ async def handle_session(self, websocket: WebSocket) -> None:
+ await websocket.accept()
+
+ request_id: str | None = None
+ stop_event = asyncio.Event()
+ send_lock = asyncio.Lock()
+ control_task: asyncio.Task[None] | None = None
+ stopped = False
+ chunks_sent = 0
+
+ try:
+ start = await self._receive_start(websocket)
+ if start is None:
+ return
+ request, output_format = start
+
+ request_id = f"video_stream-{uuid.uuid4().hex}"
+ progress = _SessionProgress()
+ async with send_lock:
+ await websocket.send_json(
+ {
+ "type": "video.start",
+ "request_id": request_id,
+ "format": output_format,
+ "config": request.model_dump(exclude_none=True),
+ }
+ )
+ progress.touch()
+
+ control_task = asyncio.create_task(
+ self._control_loop(websocket, request_id, stop_event, send_lock, progress)
+ )
+ async for chunk in self._stream_video_bytes(request, request_id, output_format, progress):
+ if stop_event.is_set():
+ stopped = True
+ break
+ async with send_lock:
+ await websocket.send_bytes(chunk)
+ chunks_sent += 1
+ progress.touch()
+
+ async with send_lock:
+ await websocket.send_json(
+ {
+ "type": "session.done",
+ "request_id": request_id,
+ "chunks": chunks_sent,
+ "stopped": stopped or stop_event.is_set(),
+ }
+ )
+ except WebSocketDisconnect:
+ logger.info("Streaming video output: client disconnected")
+ if request_id is not None:
+ await self._abort_request(request_id)
+ except Exception as e:
+ if stop_event.is_set():
+ try:
+ async with send_lock:
+ await websocket.send_json(
+ {
+ "type": "session.done",
+ "request_id": request_id,
+ "chunks": chunks_sent,
+ "stopped": True,
+ }
+ )
+ except Exception:
+ logger.debug("Failed to send stopped session.done", exc_info=True)
+ return
+ logger.exception("Streaming video output session error: %s", e)
+ await self._send_error(websocket, str(e))
+ finally:
+ if control_task is not None:
+ control_task.cancel()
+ await asyncio.gather(control_task, return_exceptions=True)
+
+ async def _receive_start(self, websocket: WebSocket) -> tuple[VideoGenerationRequest, StreamingVideoFormat] | None:
+ try:
+ raw = await asyncio.wait_for(websocket.receive_text(), timeout=self._start_timeout)
+ except asyncio.TimeoutError:
+ await self._send_error(websocket, "Timeout waiting for session.start")
+ return None
+
+ if len(raw) > _MAX_START_MESSAGE_SIZE:
+ await self._send_error(websocket, "session.start message too large")
+ return None
+
+ try:
+ msg = json.loads(raw)
+ except json.JSONDecodeError:
+ await self._send_error(websocket, "Invalid JSON in session.start")
+ return None
+
+ if not isinstance(msg, dict):
+ await self._send_error(websocket, "session.start must be a JSON object")
+ return None
+
+ if msg.get("type") != "session.start":
+ await self._send_error(websocket, f"Expected session.start, got: {msg.get('type')}")
+ return None
+
+ try:
+ output_format = self._coerce_output_format(msg.pop("format", "m4s"))
+ except ValueError as e:
+ await self._send_error(websocket, str(e))
+ return None
+
+ request_data = self._coerce_request_data(msg)
+ try:
+ request = VideoGenerationRequest(**request_data)
+ except ValidationError as e:
+ await self._send_error(websocket, f"Invalid session.start: {e}")
+ return None
+
+ configured_model = self._model_name
+ if request.model is not None and configured_model is not None and request.model != configured_model:
+ await self._send_error(
+ websocket,
+ f"Model mismatch: request specifies '{request.model}' but server is running '{configured_model}'.",
+ )
+ return None
+
+ return request, output_format
+
+ @staticmethod
+ def _coerce_request_data(msg: dict[str, Any]) -> dict[str, Any]:
+ request_data = {k: v for k, v in msg.items() if k not in {"type", "format", "sampling_params"}}
+ sampling_params = msg.get("sampling_params")
+ if isinstance(sampling_params, dict):
+ extra_params = dict(request_data.get("extra_params") or {})
+ for key, value in sampling_params.items():
+ if key in VideoGenerationRequest.model_fields and key not in request_data:
+ request_data[key] = value
+ else:
+ extra_params[key] = value
+ if extra_params:
+ request_data["extra_params"] = extra_params
+ return request_data
+
+ @staticmethod
+ def _coerce_output_format(raw_format: Any) -> StreamingVideoFormat:
+ if raw_format == "m4s":
+ return cast(StreamingVideoFormat, raw_format)
+ raise ValueError("Invalid session.start format: expected 'm4s'.")
+
+ async def _control_loop(
+ self,
+ websocket: WebSocket,
+ request_id: str,
+ stop_event: asyncio.Event,
+ send_lock: asyncio.Lock,
+ progress: _SessionProgress,
+ ) -> None:
+ while not stop_event.is_set():
+ if progress.stalled_for(self._stall_timeout):
+ await self._send_error(
+ websocket,
+ "Stall timeout: no generation progress",
+ send_lock=send_lock,
+ )
+ await self._abort_request(request_id)
+ stop_event.set()
+ return
+
+ poll_timeout = min(_CONTROL_POLL_INTERVAL, self._stall_timeout)
+ try:
+ raw = await asyncio.wait_for(
+ websocket.receive_text(),
+ timeout=poll_timeout,
+ )
+ except asyncio.TimeoutError:
+ continue
+
+ if len(raw) > _MAX_CONTROL_MESSAGE_SIZE:
+ await self._send_error(websocket, "control message too large", send_lock=send_lock)
+ continue
+
+ try:
+ msg = json.loads(raw)
+ except json.JSONDecodeError:
+ await self._send_error(websocket, "Invalid JSON message", send_lock=send_lock)
+ continue
+
+ if not isinstance(msg, dict):
+ await self._send_error(websocket, "WebSocket messages must be JSON objects", send_lock=send_lock)
+ continue
+
+ msg_type = msg.get("type")
+ if msg_type == "session.stop":
+ await self._abort_request(request_id)
+ stop_event.set()
+ return
+ if msg_type == "session.ping":
+ progress.touch()
+ try:
+ async with send_lock:
+ await websocket.send_json({"type": "session.pong"})
+ except Exception:
+ pass
+ continue
+ if msg_type == "session.prompt_update":
+ await self._send_error(
+ websocket,
+ "session.prompt_update is not supported by this backend yet",
+ send_lock=send_lock,
+ )
+ continue
+ await self._send_error(websocket, f"Unknown message type: {msg_type}", send_lock=send_lock)
+
+ async def _abort_request(self, request_id: str) -> None:
+ try:
+ abort = getattr(self._engine_client, "abort", None)
+ if callable(abort):
+ result = abort(request_id)
+ if inspect.isawaitable(result):
+ await result
+ except Exception:
+ logger.debug("Failed to abort streaming video request %s", request_id, exc_info=True)
+
+ async def _stream_video_bytes(
+ self,
+ request: VideoGenerationRequest,
+ request_id: str,
+ output_format: StreamingVideoFormat,
+ progress: _SessionProgress,
+ ) -> AsyncGenerator[bytes, None]:
+ """Yield encoded video bytes from diffusion streaming outputs."""
+ prompt, gen_params, vp = self._build_prompt_and_sampling_params(request)
+ video_codec_options = {"preset": "ultrafast", "threads": "0", "tune": "zerolatency"}
+ if isinstance(request.extra_params, dict) and "video_codec_options" in request.extra_params:
+ video_codec_options = request.extra_params["video_codec_options"]
+
+ output_fps = vp.fps or gen_params.resolved_frame_rate or 16
+ encoder = create_streaming_video_encoder(
+ output_format=output_format,
+ fps=output_fps,
+ video_codec_options=video_codec_options,
+ )
+ completed = False
+ try:
+ async for result in self._iter_generation_outputs(prompt, gen_params, request_id, progress):
+ if error := getattr(result, "error", None):
+ raise RuntimeError(str(error))
+ videos = self._extract_video_outputs(result)
+ for video in videos:
+ chunk = encoder.encode(video)
+ if chunk:
+ yield chunk
+
+ completed = True
+ final_chunk = encoder.close()
+ if final_chunk:
+ yield final_chunk
+ finally:
+ if not completed:
+ encoder.close()
+
+ def _build_prompt_and_sampling_params(
+ self,
+ request: VideoGenerationRequest,
+ ) -> tuple[OmniTextPrompt, OmniDiffusionSamplingParams, VideoParams]:
+ """Build text-only diffusion inputs for the streaming endpoint."""
+ prompt: OmniTextPrompt = OmniTextPrompt(prompt=request.prompt)
+ if request.negative_prompt is not None:
+ prompt["negative_prompt"] = request.negative_prompt
+
+ gen_params = self._resolve_default_sampling_params()
+ vp = request.resolve_video_params()
+ 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
+ if vp.fps is not None:
+ gen_params.fps = vp.fps
+ gen_params.frame_rate = float(vp.fps)
+
+ provided_fields = request.model_fields_set
+ 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 "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
+ if "flow_shift" in provided_fields and request.flow_shift is not None:
+ gen_params.extra_args["flow_shift"] = request.flow_shift
+
+ 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.",
+ )
+ gen_params.extra_args.update(request.extra_params)
+
+ self._apply_lora(request.lora, gen_params)
+ return prompt, gen_params, vp
+
+ 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):
+ 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 _iter_generation_outputs(
+ self,
+ prompt: OmniTextPrompt,
+ gen_params: OmniDiffusionSamplingParams,
+ request_id: str,
+ progress: _SessionProgress,
+ ) -> AsyncGenerator[Any, None]:
+ 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.",
+ )
+
+ for stage in stage_configs:
+ stage_type = get_stage_type(stage)
+ if stage_type != "diffusion":
+ raise HTTPException(
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
+ detail=f"Video generation only supports diffusion stages, found '{stage_type}' stage.",
+ )
+
+ engine_client = cast(Any, self._engine_client)
+ 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,
+ )
+
+ async for output in engine_client.generate(
+ prompt=prompt,
+ request_id=request_id,
+ sampling_params_list=sampling_params_list,
+ ):
+ progress.touch()
+ yield output
+
+ @staticmethod
+ def _normalize_video_outputs(videos: Any) -> list[Any]:
+ 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[Any] = []
+ 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]
+ return videos
+ return [videos]
+
+ def _extract_video_outputs(self, result: Any) -> list[Any]:
+ videos = None
+ if hasattr(result, "images") and result.images:
+ videos = result.images
+ elif hasattr(result, "request_output"):
+ request_output = result.request_output
+ if isinstance(request_output, dict) and request_output.get("images"):
+ videos = request_output["images"]
+ elif not isinstance(request_output, dict):
+ if hasattr(request_output, "images") and request_output.images:
+ videos = request_output.images
+ elif hasattr(request_output, "multimodal_output") and request_output.multimodal_output:
+ videos = request_output.multimodal_output.get("video")
+ if videos is None and hasattr(result, "multimodal_output") and result.multimodal_output:
+ videos = result.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
+ async def _send_error(
+ websocket: WebSocket,
+ message: str,
+ code: int | None = None,
+ *,
+ send_lock: asyncio.Lock | None = None,
+ ) -> None:
+ try:
+ payload: dict[str, Any] = {"type": "error", "message": message}
+ if code is not None:
+ payload["code"] = code
+ if send_lock is None:
+ await websocket.send_json(payload)
+ else:
+ async with send_lock:
+ await websocket.send_json(payload)
+ except Exception:
+ pass
diff --git a/vllm_omni/entrypoints/openai/video_api_utils.py b/vllm_omni/entrypoints/openai/video_api_utils.py
index fbb37c36b..aa6479189 100644
--- a/vllm_omni/entrypoints/openai/video_api_utils.py
+++ b/vllm_omni/entrypoints/openai/video_api_utils.py
@@ -423,16 +423,8 @@ def _coerce_audio_to_numpy(audio: Any) -> np.ndarray:
return arr.astype(np.float32)
-def _encode_video_bytes(
- video: Any,
- fps: int,
- audio: Any | None = None,
- audio_sample_rate: int | None = None,
- video_codec_options: dict[str, str] | None = None,
-) -> bytes:
- """Encode a video payload into MP4 bytes, optionally muxing audio."""
- from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
-
+def _coerce_video_to_uint8_frames(video: Any) -> np.ndarray:
+ """Convert a video payload into contiguous uint8 frames shaped (F, H, W, 3)."""
frames = _coerce_video_to_frames(video)
if not frames:
raise ValueError("No frames found to encode.")
@@ -448,13 +440,23 @@ def _encode_video_bytes(
frames_np *= 255.0
frames_u8 = np.round(frames_np).astype(np.uint8)
- # Ensure contiguous memory layout for faster PyAV muxing
- frames_u8 = np.ascontiguousarray(frames_u8)
+ return np.ascontiguousarray(frames_u8)
+
+
+def _encode_video_bytes(
+ video: Any,
+ fps: int,
+ audio: Any | None = None,
+ audio_sample_rate: int | None = None,
+ video_codec_options: dict[str, str] | None = None,
+) -> bytes:
+ """Encode a video payload into MP4 bytes, optionally muxing audio."""
+ from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
audio_np = _coerce_audio_to_numpy(audio) if audio is not None else None
return mux_video_audio_bytes(
- frames_u8,
+ _coerce_video_to_uint8_frames(video),
audio_np,
fps=float(fps),
audio_sample_rate=audio_sample_rate or 24000,
@@ -462,6 +464,55 @@ def _encode_video_bytes(
)
+class FragmentedMP4VideoEncoder:
+ """Normalize video chunks and append them to one fragmented MP4 stream."""
+
+ def __init__(
+ self,
+ *,
+ fps: int | float,
+ video_codec_options: dict[str, str] | None = None,
+ ) -> None:
+ self._fps = float(fps)
+ self._video_codec_options = video_codec_options
+ self._muxer: Any | None = None
+
+ def encode(self, video: Any) -> bytes:
+ """Encode one generated video chunk and return newly emitted fMP4 bytes."""
+ from vllm_omni.diffusion.utils.media_utils import FragmentedMP4Muxer
+
+ frames_u8 = _coerce_video_to_uint8_frames(video)
+ if self._muxer is None:
+ self._muxer = FragmentedMP4Muxer(
+ width=frames_u8.shape[2],
+ height=frames_u8.shape[1],
+ fps=self._fps,
+ video_codec_options=self._video_codec_options,
+ )
+ return self._muxer.mux_video_frames(frames_u8)
+
+ def close(self) -> bytes:
+ """Close the underlying fMP4 muxer and return trailing bytes, if any."""
+ if self._muxer is None:
+ return b""
+ return self._muxer.close()
+
+
+StreamingVideoFormat = Literal["m4s"]
+
+
+def create_streaming_video_encoder(
+ *,
+ output_format: StreamingVideoFormat,
+ fps: int | float,
+ video_codec_options: dict[str, str] | None = None,
+) -> FragmentedMP4VideoEncoder:
+ """Create an incremental encoder for the requested WebSocket video format."""
+ if output_format == "m4s":
+ return FragmentedMP4VideoEncoder(fps=fps, video_codec_options=video_codec_options)
+ raise ValueError(f"Unsupported streaming video format: {output_format}")
+
+
def encode_video_base64(
video: Any,
fps: int,
diff --git a/vllm_omni/outputs.py b/vllm_omni/outputs.py
index 2dfa1699f..b3a597365 100644
--- a/vllm_omni/outputs.py
+++ b/vllm_omni/outputs.py
@@ -175,6 +175,7 @@ class OmniRequestOutput:
final_output_type: str = "image",
stage_durations: dict[str, float] | None = None,
peak_memory_mb: float = 0.0,
+ finished: bool = True,
) -> "OmniRequestOutput":
"""Create output from diffusion model.
@@ -211,7 +212,7 @@ class OmniRequestOutput:
_custom_output=custom_output or {},
stage_durations=stage_durations or {},
peak_memory_mb=peak_memory_mb,
- finished=True,
+ finished=finished,
)
@property