[Frontend] Add midway prompt update for streaming video generation (#4652)

Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com>
This commit is contained in:
Zeyu Huang | 黃澤宇 authored and GitHub committed 2026-07-27 14:07:43 +08:00
1 parent 1f01d05603
commit eaf90d8eff
28 files changed
+2615 -133

No files matched your search

@@ -21,13 +21,16 @@ The `--diffusion-streaming-output` CLI flag is forwarded as `streaming_output=Tr
| 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. |
| Client to server | `session.interaction` | JSON text: `{"type":"session.interaction","interaction":{"event_id":"xxx","event":{"prompt":"..."},"transition_chunks":3}}` | Updates the active prompt midway through generation. `event_id` is optional and `transition_chunks` defaults to the model setting. |
| 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_metadata` | JSON text: `{"type":"video.chunk_metadata","request_id":"...","kind":"media","transport_chunk_index":0,"generation_chunk_index":0,"num_frames":9,"byte_length":1234,"started_event_ids":[],"active_event_ids":[],"completed_event_ids":[]}` | Precedes each binary frame and describes the immediately following payload. |
| 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.interaction.queued` | JSON text: `{"type":"session.interaction.queued","request_id":"...","event_id":"xxx"}` | Confirms the server queued the interaction for the active request. |
| 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. |
| Server to client | `error` | JSON text: `{"type":"error","message":"..."}` | Reports invalid input, unsupported formats, inactive requests, unsupported prompt updates, 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.
@@ -59,6 +62,17 @@ python streaming_video_client.py \
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.
Schedule midway prompt updates with `--prompt-updates`. Each entry uses `"at"` as seconds on the client clock after the server sends `video.start`:
```bash
python streaming_video_client.py \
--prompt "A serene lakeside sunrise with mist over the water." \
--prompt-updates '[
{"at": 2.5, "prompt": "A sea turtle glides past the reeds"},
{"at": 5.0, "prompt": "Sunlight breaks through the morning mist", "transition_chunks": 2}
]'
```
## Run The Gradio Demo
```bash
@@ -14,8 +14,9 @@ from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
from typing import Any
from typing import Any, TypedDict
try:
import gradio as gr
@@ -23,16 +24,49 @@ 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."
DEFAULT_TRANSITION_CHUNKS = 3
PROMPT_UPDATE_HEADERS = ["At (s)", "Prompt", "Transition Chunks"]
PROMPT_UPDATE_VALUES = [
[
4,
(
"An underwater tornado appears and affects the ocean floor in a dramatic and chaotic scene. "
"The water is murky, swirling violently, carrying debris and marine life into the vortex. "
"The tropical fish on the scene all swim in panic, trying to avoid the powerful currents. "
"The camera remains stationary, capturing the intensity of the underwater tornado as it disrupts the serene ocean floor. "
"Close-up shot emphasizing the turbulent motion and destruction."
),
3,
],
[
11,
(
"The swirling underwater vortex now seizes a heavy, encrusted treasure chest, its lid flapping open as it is smashed onto the ocean floor. "
"Gold coins and silver trinkets spill out, glittering briefly in the murky water before being swept instantly into the violent funnel. "
"The heavy wooden box tumbles end over end, colliding with floating rocks and adding to the debris field. "
"Swirling sediment and bubbles surround the spilling fortune, highlighting the chaotic power of the storm as it ravages the seabed. "
"Close-up shot emphasizing the turbulent motion and destruction."
),
3,
],
]
VIDEO_STREAM_VIEW_HTML = Path(__file__).with_name("video-stream-view.html")
VIDEO_STREAM_VIEW_JS = Path(__file__).with_name("video-stream-view.js")
class ScheduledPromptUpdate(TypedDict):
at: float
prompt: str
transition_chunks: int
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
if isinstance(value, str):
payload[key] = value.strip()
else:
payload[key] = value
def _optional_int(value: int | float | None) -> int | None:
@@ -43,11 +77,83 @@ def _optional_float(value: int | float | None) -> float | None:
return None if value is None else float(value)
def _normalize_prompt_update_row(row: list[Any] | tuple[Any, ...]) -> tuple[Any, Any, Any]:
values = []
for v in row[: len(PROMPT_UPDATE_HEADERS)]:
if isinstance(v, str):
values.append(v.strip() or None)
elif isinstance(v, float) and math.isnan(v):
values.append(None)
else:
values.append(v)
while len(values) < len(PROMPT_UPDATE_HEADERS):
values.append(None)
return values[0], values[1], values[2] # pyright: ignore[reportIndexIssue]
def _prompt_update_row_is_empty(row: list[Any] | tuple[Any, ...]) -> bool:
at, prompt, transition = _normalize_prompt_update_row(row)
return at is None and prompt is None and transition is None
def _parse_prompt_update_row(row: list[Any] | tuple[Any, ...], *, index: int) -> ScheduledPromptUpdate:
at_val, prompt_val, transition_val = _normalize_prompt_update_row(row)
row_label = f"Prompt update row {index + 1}"
if at_val is None or prompt_val is None:
raise gr.Error(f"{row_label} requires both At (s) and Prompt.")
try:
at = float(at_val)
except (TypeError, ValueError) as exc:
raise gr.Error(f"{row_label}: At (s) must be a number.") from exc
if at < 0:
raise gr.Error(f"{row_label}: At (s) must be >= 0.")
prompt = str(prompt_val).strip()
if not prompt:
raise gr.Error(f"{row_label}: Prompt must be a non-empty string.")
transition_chunks = DEFAULT_TRANSITION_CHUNKS
if transition_val is not None:
try:
transition_chunks = int(transition_val)
except (TypeError, ValueError) as exc:
raise gr.Error(f"{row_label}: Transition Chunks must be an integer.") from exc
if transition_chunks < 0:
raise gr.Error(f"{row_label}: Transition Chunks must be >= 0.")
return {
"at": at,
"prompt": prompt,
"transition_chunks": transition_chunks,
}
def _parse_prompt_updates_dataframe(rows: list[list[Any]] | None) -> list[ScheduledPromptUpdate]:
if not rows:
return []
# Single row: empty or complete.
if len(rows) == 1:
row = rows[0]
if _prompt_update_row_is_empty(row):
return []
return [_parse_prompt_update_row(row, index=0)]
updates: list[ScheduledPromptUpdate] = []
for index, row in enumerate(rows):
if _prompt_update_row_is_empty(row):
raise gr.Error(f"Prompt update row {index + 1} cannot be empty when multiple rows are present.")
updates.append(_parse_prompt_update_row(row, index=index))
return sorted(updates, key=lambda update: update["at"])
def _build_session_start(
*,
model: str,
prompt: str,
negative_prompt: str,
negative_prompt: str | None,
width: int | float | None,
height: int | float | None,
fps: int | float | None,
@@ -101,6 +207,7 @@ def build_browser_stream_config(
model: str,
prompt: str,
negative_prompt: str,
include_negative_prompt: bool,
width: int | float | None,
height: int | float | None,
fps: int | float,
@@ -118,6 +225,7 @@ def build_browser_stream_config(
flow_shift: int | float | None,
include_true_cfg_scale: bool,
true_cfg_scale: int | float | None,
prompt_updates_df: list[list[Any]] | None,
) -> tuple[str, Any]:
"""Build the browser-side WebSocket request config for the HTML/MSE player."""
if not model.strip():
@@ -128,7 +236,7 @@ def build_browser_stream_config(
payload = _build_session_start(
model=model.strip(),
prompt=prompt.strip(),
negative_prompt=negative_prompt,
negative_prompt=negative_prompt if include_negative_prompt else None,
width=width,
height=height,
fps=fps,
@@ -141,15 +249,21 @@ def build_browser_stream_config(
true_cfg_scale=true_cfg_scale if include_true_cfg_scale else None,
seed=seed if include_seed else None,
)
prompt_updates = _parse_prompt_updates_dataframe(prompt_updates_df)
config = {
"url": f"ws://{host}:{int(port)}/v1/realtime/video",
"payload": payload,
}
if prompt_updates:
config["prompt_updates"] = prompt_updates
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:
with gr.Blocks(
title="Streaming Video Generation",
css="textarea:disabled, input:disabled { color: var(--body-text-color-subdued); -webkit-text-fill-color: var(--body-text-color-subdued); background-color: var(--background-fill-secondary); }",
) as demo:
gr.Markdown("# Streaming Video Generation")
gr.Markdown(
"Connects to `WS /v1/realtime/video` in the browser and appends fMP4 chunks "
@@ -162,7 +276,6 @@ def create_demo() -> gr.Blocks:
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)
@@ -170,49 +283,66 @@ def create_demo() -> gr.Blocks:
with gr.Row():
fps = gr.Number(label="FPS", value=16, precision=0)
num_frames = gr.Number(label="Num Frames", value=132, precision=0)
num_frames = gr.Number(label="Num Frames", value=429, 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.Accordion("Advanced Sampling", open=False):
with gr.Row():
with gr.Column():
negative_prompt = gr.Textbox(label="Negative Prompt", value="", lines=1, interactive=False)
include_negative_prompt = gr.Checkbox(label="Send", value=False)
with gr.Row():
with gr.Column(scale=0, min_width=150):
guidance_scale = gr.Number(
label="Guidance Scale",
value=1.0,
interactive=True,
)
include_guidance_scale = gr.Checkbox(label="Send", value=True)
with gr.Column(scale=0, min_width=150):
guidance_scale_2 = gr.Number(
label="Guidance Scale 2",
value=None,
interactive=False,
)
include_guidance_scale_2 = gr.Checkbox(label="Send", value=False)
with gr.Column(scale=0, min_width=150):
true_cfg_scale = gr.Number(
label="True CFG Scale",
value=None,
interactive=False,
)
include_true_cfg_scale = gr.Checkbox(label="Send", value=False)
with gr.Column(scale=0, min_width=150):
boundary_ratio = gr.Number(
label="Boundary Ratio",
value=None,
interactive=False,
)
include_boundary_ratio = gr.Checkbox(label="Send", value=False)
with gr.Column(scale=0, min_width=150):
flow_shift = gr.Number(label="Flow Shift", value=None, interactive=False)
include_flow_shift = gr.Checkbox(label="Send", value=False)
with gr.Column(scale=0, min_width=150):
seed = gr.Number(label="Seed", value=42, precision=0, interactive=True)
include_seed = gr.Checkbox(label="Send", value=True)
with gr.Accordion("Prompt Updates", open=True):
gr.Markdown(
"Schedule midway text `session.interaction` messages after `video.start`. "
"With one row, leave it fully empty or fill both **At (s)** and **Prompt**. "
"With multiple rows, every row must be complete."
)
prompt_updates_df = gr.Dataframe(
headers=PROMPT_UPDATE_HEADERS,
datatype=["number", "str", "number"],
type="array",
row_count=1,
row_limits=(1, None),
column_count=3,
value=PROMPT_UPDATE_VALUES,
interactive=True,
)
with gr.Row():
start_button = gr.Button("Start", variant="primary", elem_id="streaming-video-start")
@@ -231,6 +361,7 @@ def create_demo() -> gr.Blocks:
model,
prompt,
negative_prompt,
include_negative_prompt,
width,
height,
fps,
@@ -248,8 +379,10 @@ def create_demo() -> gr.Blocks:
flow_shift,
include_true_cfg_scale,
true_cfg_scale,
prompt_updates_df,
]
for toggle, field in [
(include_negative_prompt, negative_prompt),
(include_guidance_scale, guidance_scale),
(include_guidance_scale_2, guidance_scale_2),
(include_seed, seed),
@@ -13,10 +13,12 @@ Requirements:
import argparse
import asyncio
import contextlib
import io
import json
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -39,17 +41,69 @@ HELIOS_DISTILLED_EXTRA_PARAMS = {
"is_amplify_first_chunk": True,
}
DEFAULT_TRANSITION_CHUNKS = 3
def _json_object(value: str, *, argument_name: str) -> dict[str, Any]:
@dataclass(frozen=True)
class ScheduledPromptUpdate:
"""Client-side schedule entry for a midway text ``session.interaction``."""
at: float
prompt: str
transition_chunks: int
def _parse_extra_params(value: 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
raise argparse.ArgumentTypeError(f"--extra-params must be valid JSON: {exc}") from exc
if not isinstance(parsed, dict):
raise argparse.ArgumentTypeError(f"{argument_name} must be a JSON object")
raise argparse.ArgumentTypeError("--extra-params must be a JSON object")
return parsed
def _parse_prompt_updates(value: str) -> list[ScheduledPromptUpdate]:
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
raise argparse.ArgumentTypeError(f"--prompt-updates must be valid JSON: {exc}") from exc
if not isinstance(parsed, list):
raise argparse.ArgumentTypeError("--prompt-updates must be a JSON array")
updates: list[ScheduledPromptUpdate] = []
for index, item in enumerate(parsed):
try:
at = float(item["at"])
prompt = item["prompt"]
except (KeyError, TypeError, ValueError) as exc:
raise argparse.ArgumentTypeError(
f"--prompt-updates[{index}] must have valid 'at' and 'prompt' fields"
) from exc
if at < 0:
raise argparse.ArgumentTypeError(f"--prompt-updates[{index}].at must be >= 0")
if not isinstance(prompt, str) or not prompt.strip():
raise argparse.ArgumentTypeError(f"--prompt-updates[{index}].prompt must be a non-empty string")
try:
transition_chunks = item.get("transition_chunks", DEFAULT_TRANSITION_CHUNKS)
transition_chunks = int(transition_chunks)
except (TypeError, ValueError) as exc:
raise argparse.ArgumentTypeError(f"--prompt-updates[{index}].transition_chunks must be an integer") from exc
if transition_chunks < 0:
raise argparse.ArgumentTypeError(f"--prompt-updates[{index}].transition_chunks must be >= 0")
updates.append(
ScheduledPromptUpdate(
at=at,
prompt=prompt,
transition_chunks=transition_chunks,
)
)
return sorted(updates, key=lambda update: update.at)
def _maybe_set(payload: dict[str, Any], key: str, value: Any) -> None:
if value is not None:
payload[key] = value
@@ -88,6 +142,31 @@ def build_session_start(args: argparse.Namespace) -> dict[str, Any]:
return payload
async def _run_prompt_update_scheduler(
websocket: Any,
updates: list[ScheduledPromptUpdate],
*,
video_started_at: float,
send_lock: asyncio.Lock,
) -> None:
for update in updates:
delay = (video_started_at + update.at) - time.perf_counter()
if delay > 0:
await asyncio.sleep(delay)
payload = {
"type": "session.interaction",
"interaction": {
"event_id": f"scheduled-{update.at:.3f}",
"event": {"prompt": update.prompt},
"transition_chunks": update.transition_chunks,
},
}
async with send_lock:
await websocket.send(json.dumps(payload, ensure_ascii=False))
print(f"Sent session.interaction at t={update.at:.2f}s: {json.dumps(payload, ensure_ascii=False)}")
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:
@@ -144,11 +223,15 @@ async def stream_video(args: argparse.Namespace) -> None:
stream_format = "m4s"
done = False
ws = None
send_lock = asyncio.Lock()
prompt_update_task: asyncio.Task[None] | None = None
pending_chunk_metadata: dict[str, Any] | None = None
try:
async with connect(url, max_size=None) as websocket:
ws = websocket
await websocket.send(json.dumps(payload))
async with send_lock:
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()
@@ -159,6 +242,9 @@ async def stream_video(args: argparse.Namespace) -> None:
now = time.perf_counter()
if isinstance(message, bytes):
# Use chunk metadata just received before video chunks
metadata = pending_chunk_metadata or {}
pending_chunk_metadata = None
chunks.append(message)
chunk_bytes = len(message)
chunk_elapsed = now - last_chunk_at
@@ -176,8 +262,11 @@ async def stream_video(args: argparse.Namespace) -> None:
total_elapsed = now - started_at
print(
f"[chunk {len(chunks):03d}] "
f"kind={metadata.get('kind', 'media')} "
f"bytes={chunk_bytes} "
f"frames={chunk_frames} "
f"frames={metadata.get('num_frames', chunk_frames)} "
f"generation_chunk={metadata.get('generation_chunk_index')} "
f"active_interactions={metadata.get('active_event_ids', [])} "
f"elapsed={chunk_elapsed:.2f}s "
f"total_bytes={total_bytes} "
f"total_frames={total_frames} "
@@ -190,9 +279,26 @@ async def stream_video(args: argparse.Namespace) -> None:
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')}")
if args.prompt_updates and prompt_update_task is None:
video_started_at = time.perf_counter()
prompt_update_task = asyncio.create_task(
_run_prompt_update_scheduler(
websocket,
args.prompt_updates,
video_started_at=video_started_at,
send_lock=send_lock,
)
)
elif msg_type == "video.chunk_metadata":
# Save the metadata for the incoming video chunk
pending_chunk_metadata = msg
elif msg_type == "session.interaction.queued":
print(f"Interaction queued by server: event_id={msg.get('event_id')}")
elif msg_type == "session.done":
print(f"Session complete: {json.dumps(msg, ensure_ascii=False)}")
done = True
if prompt_update_task is not None:
prompt_update_task.cancel()
break
elif msg_type == "error":
print(f"ERROR: {msg.get('message', msg)}")
@@ -202,12 +308,17 @@ async def stream_video(args: argparse.Namespace) -> None:
print("\nInterrupted. Asking server to stop the active session...")
if ws is not None:
try:
await ws.send(json.dumps({"type": "session.stop"}))
async with send_lock:
await ws.send(json.dumps({"type": "session.stop"}))
await ws.close(code=1000, reason="client interrupted")
except Exception:
pass
raise
finally:
if prompt_update_task is not None:
prompt_update_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await prompt_update_task
print(
"Saving video... (May take a while. Remuxing concatenated chunks into one progressive MP4 file with total duration metadata in file header)"
)
@@ -232,7 +343,15 @@ def parse_args() -> argparse.Namespace:
)
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.",
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")
@@ -241,7 +360,7 @@ def parse_args() -> argparse.Namespace:
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("--num-frames", type=int, default=429, 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")
@@ -260,10 +379,44 @@ def parse_args() -> argparse.Namespace:
)
parser.add_argument(
"--extra-params",
type=lambda value: _json_object(value, argument_name="--extra-params"),
type=_parse_extra_params,
default=None,
help="JSON object merged into extra_params; overrides preset keys on conflict.",
)
parser.add_argument(
"--prompt-updates",
type=_parse_prompt_updates,
default=json.dumps(
[
{
"at": 4,
"prompt": (
"An underwater tornado appears and affects the ocean floor in a dramatic and chaotic scene. "
"The water is murky, swirling violently, carrying debris and marine life into the vortex. "
"The tropical fish on the scene all swim in panic, trying to avoid the powerful currents. "
"The camera remains stationary, capturing the intensity of the underwater tornado as it disrupts the serene ocean floor. "
"Close-up shot emphasizing the turbulent motion and destruction."
),
},
{
"at": 11,
"prompt": (
"The swirling underwater vortex now seizes a heavy, encrusted treasure chest, its lid flapping open as it is smashed onto the ocean floor. "
"Gold coins and silver trinkets spill out, glittering briefly in the murky water before being swept instantly into the violent funnel. "
"The heavy wooden box tumbles end over end, colliding with floating rocks and adding to the debris field. "
"Swirling sediment and bubbles surround the spilling fortune, highlighting the chaotic power of the storm as it ravages the seabed. "
"Close-up shot emphasizing the turbulent motion and destruction."
),
# "transition_chunks": 3,
},
]
),
help=(
"JSON array of scheduled prompt updates. Each object requires "
"'at' (seconds after video.start) and 'prompt'. Optional "
f"'transition_chunks' defaults to {DEFAULT_TRANSITION_CHUNKS}."
),
)
return parser.parse_args()
@@ -14,6 +14,7 @@
let totalBytes = 0;
let done = false;
let objectUrl = null;
let promptUpdateTimers = [];
function el(id) {
return document.getElementById(id);
@@ -73,7 +74,50 @@
} catch (_) { }
}
function clearPromptUpdateTimers() {
for (const timer of promptUpdateTimers) {
clearTimeout(timer);
}
promptUpdateTimers = [];
}
function schedulePromptUpdates(updates) {
clearPromptUpdateTimers();
if (!Array.isArray(updates) || updates.length === 0) return;
const videoStartedAt = performance.now();
for (const update of updates) {
const atSeconds = Number(update.at);
if (!Number.isFinite(atSeconds) || atSeconds < 0) continue;
const delayMs = Math.max(0, atSeconds * 1000 - (performance.now() - videoStartedAt));
const transitionChunks = Number.isFinite(Number(update.transition_chunks))
? Number(update.transition_chunks)
: 3;
const timer = setTimeout(() => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const payload = {
type: "session.interaction",
interaction: {
event_id: "scheduled-" + atSeconds.toFixed(3),
event: { prompt: update.prompt },
transition_chunks: transitionChunks,
},
};
try {
ws.send(JSON.stringify(payload));
log("Sent session.interaction at t=" + atSeconds.toFixed(2) + "s: " + JSON.stringify(payload));
} catch (err) {
log("Failed to send session.interaction: " + err.message);
}
}, delayMs);
promptUpdateTimers.push(timer);
}
}
function stopCurrent() {
clearPromptUpdateTimers();
if (ws && ws.readyState === WebSocket.OPEN) {
try { ws.send(JSON.stringify({ type: "session.stop" })); } catch (_) { }
try { ws.close(); } catch (_) { }
@@ -148,6 +192,11 @@
const msg = JSON.parse(event.data);
if (msg.type === "video.start") {
log("Video session started: request_id=" + (msg.request_id || "") + " format=" + (msg.format || ""));
schedulePromptUpdates(config.prompt_updates);
} else if (msg.type === "video.chunk_metadata") {
log("Chunk metadata: " + JSON.stringify(msg));
} else if (msg.type === "session.interaction.queued") {
log("Interaction queued by server: event_id=" + (msg.event_id || ""));
} else if (msg.type === "session.done") {
done = true;
setStatus("Done");
+138
View File
@@ -0,0 +1,138 @@
# Realtime Video Prompt Interaction Protocol
This document specifies the prompt-update subset of the realtime video
interaction protocol exposed by:
```text
WebSocket /v1/realtime/video
```
A WebSocket connection contains exactly one video-generation request. Clients
therefore do not send a request or session ID with an interaction. The server
assigns a `request_id` in `video.start` and includes it in subsequent
server-to-client messages.
## Interaction Request
After `video.start`, a client may submit a prompt update:
```json
{
"type": "session.interaction",
"interaction": {
"event_id": "ui-update-42",
"event": {
"prompt": "A snowy street illuminated by warm lanterns"
},
"transition_chunks": 3
}
}
```
`interaction` has the following fields:
- `event_id` (optional string): session-unique idempotency key. The
server preserves a client-provided value or generates a UUID when omitted.
- `event.prompt` (required string): non-empty target prompt.
over which the prompt reaches its target.
- `transition_chunks` (optional non-negative integer): model-generation chunks
over which the prompt reaches its target. If omitted, the model's default transition is used.
Note: Although `["interaction"]["event"]` only contains `prompt`, still consider using `OmniTextPrompt` for this part (with total=false) so that other fields will be supported in the future. Currently, `vllm_omni/diffusion/worker/diffusion_model_runner.py` already takes care of enforcing subset format, routing to meaningful input handing routines, and raising `NotImplementedError`. Only need to adapt this design to the input data structure above.
### Transition Semantics
Prompt transitions are target updates and begin at the next eligible model
generation boundary after the interaction is processed.
- `transition_chunks = 0` is a hard update: the first affected generation
chunk uses the target prompt.
- `transition_chunks = N`, where `N > 0`, reaches the target in the `N`th
affected generation chunk. For example, a three-chunk linear transition uses
progress values `1/3`, `2/3`, and `1`.
### Idempotency and Replacement
Within a session:
- repeating an `event_id` with the same payload is a retry and must not
apply the update twice;
- repeating an `event_id` with a different payload is rejected with
`event_id_conflict`;
- a newer prompt target supersedes any older pending prompt target;
- when a new target interrupts an active transition, its transition starts
from the prompt state resolved at its effective boundary.
## Queue Acknowledgement
The server quickly acknowledges admission to the interaction queue:
```json
{
"type": "session.interaction.queued",
"request_id": "video_stream-...",
"event_id": "ui-update-42"
}
```
`queued` confirms syntax validation, idempotency handling, and queue admission.
It does not claim that the model has scheduled or applied the update. A later
failure is reported as a correlated error:
```json
{
"type": "error",
"request_id": "video_stream-...",
"event_id": "ui-update-42",
"code": "interaction_failed",
"message": "The active model does not support prompt updates"
}
```
## Video Chunk Metadata
Each emitted binary payload is preceded by a JSON metadata message. The
metadata message and its binary payload form one ordered pair and must not be
interleaved with another outbound message.
```json
{
"type": "video.chunk_metadata",
"request_id": "video_stream-...",
"kind": "media",
"transport_chunk_index": 7,
"generation_chunk_index": 6,
"num_frames": 9,
"byte_length": 184320,
"started_event_ids": ["ui-update-42"],
"active_event_ids": ["ui-update-42"],
"completed_event_ids": []
}
```
- `transport_chunk_index` identifies the binary WebSocket payload.
- `generation_chunk_index` identifies the model-generation chunk represented
by the payload.
- `started_event_ids` lists interactions that first affect this
generation chunk.
- `active_event_ids` lists interactions contributing to this generation
chunk, including an ongoing transition.
- `completed_event_ids` lists interactions that reach their target in
this generation chunk.
Encoder trailer bytes are emitted as:
```json
{
"type": "video.chunk_metadata",
"request_id": "video_stream-...",
"kind": "trailer",
"transport_chunk_index": 8,
"generation_chunk_index": null,
"num_frames": 0,
"byte_length": 824,
"started_event_ids": [],
"active_event_ids": [],
"completed_event_ids": []
}
```
@@ -341,7 +341,9 @@ class TestPipelineStreamingOutputToEntrypoint:
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_json()["type"] == "video.chunk_metadata"
assert ws.receive_bytes() == b"pipeline-fmp4-0"
assert ws.receive_json()["type"] == "video.chunk_metadata"
assert ws.receive_bytes() == b"pipeline-fmp4-1"
done = ws.receive_json()
assert done["type"] == "session.done"
@@ -453,7 +455,6 @@ class TestPipelineStreamingOutputToEntrypoint:
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
+574
View File
@@ -0,0 +1,574 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for midway prompt update across runner, batch, pipeline, and entrypoint layers."""
from __future__ import annotations
import asyncio
import queue
import time
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock, patch
import pytest
import torch
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.models.helios.pipeline_helios import HeliosPipeline
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
from vllm_omni.diffusion.worker.input_batch import InputBatch
from vllm_omni.diffusion.worker.utils import StepRequestState
from vllm_omni.engine.async_omni_engine import StageRuntimeInfo
from vllm_omni.engine.messages import (
ErrorMessage,
InteractionMessage,
ShutdownRequestMessage,
StageSubmissionMessage,
)
from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState
from vllm_omni.engine.stage_client import StagePoolClient
from vllm_omni.engine.stage_init_utils import StageMetadata
from vllm_omni.engine.stage_pool import StagePool
from vllm_omni.entrypoints.async_omni import AsyncEventResolver, AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniInteractionPrompt
from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
@pytest.fixture
def pipeline() -> HeliosPipeline:
pipeline = object.__new__(HeliosPipeline)
pipeline.device = torch.device("cpu")
pipeline.transformer = SimpleNamespace(dtype=torch.float32) # pyright: ignore[reportAttributeAccessIssue]
pipeline.encode_prompt = MagicMock(
return_value=(
torch.full((1, 4, 2), 2.0),
None,
)
)
pipeline._prepare_next_chunk = MagicMock()
return pipeline
def _make_diffusion_request_state(*, request_id: str = "req-1") -> StepRequestState:
state = StepRequestState(
request_id=request_id,
sampling=SimpleNamespace(num_outputs_per_prompt=1, max_sequence_length=226), # pyright: ignore[reportArgumentType]
prompt="hello",
)
state.prompt_embeds = torch.zeros(1, 4, 2)
state.extra = {}
return state
def _make_diffusion_model_runner(*, pipeline, streaming_output: bool = True) -> DiffusionModelRunner:
runner = object.__new__(DiffusionModelRunner)
runner.pipeline = pipeline
runner.state_cache = {}
runner.od_config = SimpleNamespace( # pyright: ignore[reportAttributeAccessIssue]
model_class_name="HeliosPipeline",
streaming_output=streaming_output,
)
runner._supports_step_mode = lambda: True
return runner
def _prompt_interaction(
prompt: str = "updated",
*,
event_id: str = "ui-update-1",
transition_chunks: int | None = None,
) -> OmniInteractionPrompt:
interaction: dict[str, Any] = {
"event_id": event_id,
"event": {"prompt": prompt},
}
if transition_chunks is not None:
interaction["transition_chunks"] = transition_chunks
return cast(OmniInteractionPrompt, interaction)
class TestPromptUpdateExecution:
"""Runner, InputBatch cache, and pipeline chunk-boundary behavior."""
def test_runner_interaction_delegates_to_helios_pipeline(self, pipeline: HeliosPipeline) -> None:
"""Runner encodes the new prompt and queues pending embeds on the request state."""
runner = _make_diffusion_model_runner(pipeline=pipeline)
state = _make_diffusion_request_state()
runner.state_cache["req-1"] = state
runner.submit_interaction("req-1", _prompt_interaction(transition_chunks=2))
pipeline.encode_prompt.assert_called_once() # pyright: ignore[reportAttributeAccessIssue]
pending = state.extra["pending_prompt_update"]
assert pending["event_id"] == "ui-update-1"
assert pending["transition_chunks"] == 2
assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0))
def test_runner_prompt_update_rejects_unsupported_pipeline(self) -> None:
"""Runner rejects prompt updates when the pipeline lacks prompt-update support."""
class _UnsupportedPipeline:
supports_step_execution = True
runner = _make_diffusion_model_runner(pipeline=_UnsupportedPipeline())
with pytest.raises(ValueError, match="not supported"):
runner.submit_interaction("req-1", _prompt_interaction())
def test_runner_prompt_update_rejects_missing_request(self, pipeline) -> None:
"""Runner rejects prompt updates when no active request state exists."""
runner = _make_diffusion_model_runner(pipeline=pipeline)
with pytest.raises(ValueError, match="No active request state"):
runner.submit_interaction("missing", _prompt_interaction())
@pytest.mark.parametrize(
"interaction",
[
{},
{"multi_modal_data": {"camera": {"type": "pose"}}},
{"event": {"prompt": "updated", "multi_modal_data": {"camera": {"type": "pose"}}}},
],
)
def test_runner_interaction_rejects_structural_payloads_until_implemented(
self,
pipeline: HeliosPipeline,
interaction: dict[str, Any],
) -> None:
"""Unsupported interaction dict shapes are preserved to and rejected by the runner."""
runner = _make_diffusion_model_runner(pipeline=pipeline)
runner.state_cache["req-1"] = _make_diffusion_request_state()
with pytest.raises(NotImplementedError, match="Only text-only prompt update interactions"):
runner.submit_interaction("req-1", cast(Any, interaction))
def test_input_batch_refreshes_prompt_embeds_on_version_change(self) -> None:
"""InputBatch rebuilds prompt_embeds when prompt_update_version changes."""
state = StepRequestState(
request_id="req-1",
sampling=SimpleNamespace(), # pyright: ignore[reportArgumentType]
prompt="hello",
)
state.prompt_embeds = torch.zeros(1, 2, 3)
state.latents = torch.zeros(1, 2)
state.timesteps = torch.tensor([1.0])
state.extra["prompt_update_version"] = 0
batch = InputBatch.make_batch([state])
assert torch.equal(batch.prompt_embeds, torch.zeros(1, 2, 3)) # pyright: ignore[reportArgumentType]
state.prompt_embeds = torch.ones(1, 2, 3)
state.extra["prompt_update_version"] = 1
refreshed = InputBatch.make_batch([state], cached_batch=batch)
assert torch.equal(refreshed.prompt_embeds, torch.ones(1, 2, 3)) # pyright: ignore[reportArgumentType]
def test_helios_prepare_prompt_update_queues_pending_target(self, pipeline: HeliosPipeline) -> None:
"""HeliosPipeline queues target embeds without mutating current prompt_embeds."""
state = _make_diffusion_request_state()
pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2)
pending = state.extra["pending_prompt_update"]
assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0))
assert pending["transition_chunks"] == 2
assert torch.equal(state.prompt_embeds, torch.zeros(1, 4, 2)) # pyright: ignore[reportArgumentType]
def test_helios_prepare_prompt_update_rejects_before_initial_generation(self, pipeline: HeliosPipeline) -> None:
"""Reject prompt updates submitted before initial prompt embeds exist."""
state = _make_diffusion_request_state()
state.prompt_embeds = None
with pytest.raises(
ValueError,
match="prompt_update is not allowed before initial generation has started",
):
pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2)
assert "pending_prompt_update" not in state.extra
def test_helios_apply_prompt_update_at_chunk_boundary_starts_transition(self, pipeline: HeliosPipeline) -> None:
"""At chunk boundary, starts transition state and bumps prompt_update_version."""
state = _make_diffusion_request_state()
pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2)
pipeline._apply_prompt_update_at_chunk_boundary(state)
assert state.extra.get("prompt_update_state") is not None
assert torch.equal(state.prompt_embeds, torch.ones(1, 4, 2)) # pyright: ignore[reportArgumentType]
assert state.extra["prompt_update_version"] == 1
assert state.extra["prompt_update_chunk_metadata"] == {
"started_event_ids": ["ui-update-1"],
"active_event_ids": ["ui-update-1"],
"completed_event_ids": [],
}
def test_helios_apply_prompt_update_advances_transition_over_chunks(self, pipeline: HeliosPipeline) -> None:
"""At chunk boundary, interpolates embeds until the target prompt is reached."""
state = _make_diffusion_request_state()
pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=3)
pipeline._apply_prompt_update_at_chunk_boundary(state)
assert torch.allclose(state.prompt_embeds, torch.full((1, 4, 2), 2.0 / 3.0)) # pyright: ignore[reportArgumentType]
for _ in range(2):
pipeline._apply_prompt_update_at_chunk_boundary(state)
assert torch.allclose(state.prompt_embeds, torch.full((1, 4, 2), 2.0)) # pyright: ignore[reportArgumentType]
assert state.extra["prompt_update_version"] == 3
# Further chunk boundaries after completion must not bump the version.
pipeline._apply_prompt_update_at_chunk_boundary(state)
assert state.extra["prompt_update_version"] == 3
class TestPromptUpdateIntegration:
"""AsyncOmni prompt update through orchestrator, inline client, and runner."""
@pytest.mark.asyncio
async def test_orchestrator_prompt_update_failure_surfaces_non_fatal_error(
self,
) -> None:
"""Worker-side prompt_update rejection is reported without crashing the orchestrator."""
class _RejectingStagePool:
async def submit_interaction(self, *args: Any, **kwargs: Any) -> None:
del args, kwargs
raise ValueError("prompt embeds are not ready")
output_queue: asyncio.Queue[ErrorMessage] = asyncio.Queue()
orchestrator = object.__new__(Orchestrator)
orchestrator.stage_pools = [_RejectingStagePool()] # pyright: ignore[reportAttributeAccessIssue]
orchestrator.output_async_queue = output_queue # pyright: ignore[reportAttributeAccessIssue]
orchestrator.request_states = {"req-1": OrchestratorRequestState(request_id="req-1")}
await orchestrator._handle_interaction(
InteractionMessage(
request_id="req-1",
interaction=_prompt_interaction("new scene", event_id="ui-update-1", transition_chunks=2),
)
)
msg = output_queue.get_nowait()
assert msg == ErrorMessage(
error="Failed interaction for request req-1: prompt embeds are not ready",
fatal=False,
request_id="req-1",
event_id="ui-update-1",
stage_id=0,
)
@pytest.mark.asyncio
async def test_runner_prompt_update_failure_surfaces_non_fatal_error(self, pipeline: HeliosPipeline) -> None:
"""Runner-side prepare_prompt_update rejection is reported through the orchestrator."""
pipeline.prepare_prompt_update = MagicMock( # pyright: ignore[reportAttributeAccessIssue, reportMethodAssignment]
side_effect=ValueError("prompt embeds are not ready")
)
runner = _make_diffusion_model_runner(pipeline=pipeline)
runner.state_cache["req-1"] = _make_diffusion_request_state(request_id="req-1")
prompt_update_engine = self._PromptUpdateEngine(self._IncrementalStreamingPipeline(), runner)
inline_client = self._make_inline_pipeline_client(prompt_update_engine)
output_queue: asyncio.Queue[ErrorMessage] = asyncio.Queue()
orchestrator = object.__new__(Orchestrator)
orchestrator.stage_pools = [ # pyright: ignore[reportAttributeAccessIssue]
StagePool(0, cast(StagePoolClient, inline_client))
]
orchestrator.output_async_queue = output_queue # pyright: ignore[reportAttributeAccessIssue]
orchestrator.request_states = {"req-1": OrchestratorRequestState(request_id="req-1")}
try:
await orchestrator._handle_interaction(
InteractionMessage(
request_id="req-1",
interaction=_prompt_interaction("new scene", event_id="ui-update-1", transition_chunks=2),
)
)
finally:
inline_client.shutdown()
pipeline.prepare_prompt_update.assert_called_once_with( # pyright: ignore[reportAttributeAccessIssue]
runner.state_cache["req-1"],
"new scene",
"ui-update-1",
2,
)
msg = output_queue.get_nowait()
assert msg == ErrorMessage(
error="Failed interaction for request req-1: prompt embeds are not ready",
fatal=False,
request_id="req-1",
event_id="ui-update-1",
stage_id=0,
)
@pytest.mark.asyncio
async def test_prompt_update_reaches_runner_from_async_omni(self, pipeline: HeliosPipeline) -> None:
"""Midway prompt update submitted via AsyncOmni reaches the diffusion runner."""
streaming_pipeline = self._IncrementalStreamingPipeline()
runner = _make_diffusion_model_runner(pipeline=pipeline)
prompt_update_engine = self._PromptUpdateEngine(streaming_pipeline, runner)
inline_client = self._make_inline_pipeline_client(prompt_update_engine)
fixture = _build_harness([inline_client])
omni = self._make_async_omni(self._OrchestratorBridgeEngine(fixture))
generate_task: asyncio.Task[list[OmniRequestOutput]] | None = None
try:
generate_task = asyncio.create_task(self._collect_generate_outputs(omni))
await _wait_for(
lambda: (
len(runner.state_cache) > 0
and any(state.external_request_id == "req-omni" for state in omni.request_states.values())
)
)
internal_request_id = next(iter(runner.state_cache))
await omni.submit_interaction_async(
"req-omni",
interaction=_prompt_interaction("new scene", event_id="ui-update-1", transition_chunks=2),
)
outputs = await generate_task
pending = runner.state_cache[internal_request_id].extra["pending_prompt_update"]
assert pending["transition_chunks"] == 2
assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0))
pipeline.encode_prompt.assert_called_once() # pyright: ignore[reportAttributeAccessIssue]
assert [output.custom_output["chunk"] for output in outputs] == [0, 1]
finally:
if generate_task is not None and not generate_task.done():
generate_task.cancel()
await asyncio.gather(generate_task, return_exceptions=True)
await self._shutdown_pipeline_omni_harness(omni, fixture, inline_client)
@staticmethod
async def _collect_generate_outputs(omni: AsyncOmni) -> list[OmniRequestOutput]:
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)
return outputs
@classmethod
def _make_inline_pipeline_client(cls, engine: _PromptUpdateEngine) -> InlineStageDiffusionClient:
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,
)
with patch.object(InlineStageDiffusionClient, "_enrich_config"):
with patch(
"vllm_omni.diffusion.inline_stage_diffusion_client.DiffusionEngine.make_engine",
return_value=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 # pyright: ignore[reportAttributeAccessIssue]
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.prom_metrics = MagicMock()
omni.mod_metrics = MagicMock()
omni.resolve_sampling_params_list = lambda params, allow_delta_coercion: params # pyright: ignore[reportAttributeAccessIssue]
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 # pyright: ignore[reportAttributeAccessIssue]
omni._log_summary_and_cleanup = lambda request_id: omni.request_states.pop(request_id, None) # pyright: ignore[reportAttributeAccessIssue]
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)
class _IncrementalStreamingPipeline:
"""Returns one streaming chunk per ``step_outputs`` call."""
supports_step_execution = True
def __init__(self) -> None:
self.requests: list[Any] = []
self._step_index = 0
def step_outputs(self, request):
self.requests.append(request)
if self._step_index == 0:
self._step_index += 1
return [DiffusionOutput(output={"chunk": 0}, finished=False)]
return [DiffusionOutput(output={"chunk": 1}, finished=True)]
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 submit_interaction_async(
self,
request_id: str,
*,
interaction: dict[str, Any],
) -> None:
self._fixture.request_sync_q.put_nowait(
InteractionMessage(
request_id=request_id,
interaction=cast(Any, interaction),
)
)
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
class _PromptUpdateEngine:
"""DiffusionEngine stand-in that streams chunks and routes prompt_update to a runner."""
def __init__(
self,
streaming_pipeline: TestPromptUpdateIntegration._IncrementalStreamingPipeline,
runner: DiffusionModelRunner,
) -> None:
self.streaming_pipeline = streaming_pipeline
self._runner = runner
self.executor = SimpleNamespace(
register_failure_callback=MagicMock(),
check_health=MagicMock(),
)
async def step_streaming(self, request):
if request.request_id not in self._runner.state_cache:
state = _make_diffusion_request_state(request_id=request.request_id)
self._runner.state_cache[request.request_id] = state
state = self._runner.state_cache[request.request_id]
outputs = self.streaming_pipeline.step_outputs(request)
output = outputs[0]
custom_output = cast(dict[str, Any], output.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,
)
]
deadline = time.monotonic() + 5.0
while "pending_prompt_update" not in state.extra:
if time.monotonic() >= deadline:
raise TimeoutError("timed out waiting for prompt update during streaming")
await asyncio.sleep(0.01)
outputs = self.streaming_pipeline.step_outputs(request)
output = outputs[0]
custom_output = cast(dict[str, Any], output.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 collective_rpc(self, method, timeout, args, kwargs, unique_reply_rank):
del timeout, kwargs, unique_reply_rank
if method == "submit_interaction":
request_id, interaction = args
self._runner.submit_interaction(request_id, interaction)
return None
raise NotImplementedError(f"collective_rpc not mocked for {method!r}")
def abort(self, request_id: str) -> None:
del request_id
@@ -16,6 +16,7 @@ from fastapi import FastAPI, WebSocket
from pytest_mock import MockerFixture
from starlette.testclient import TestClient
from tests.helpers.media import generate_synthetic_image
from vllm_omni.entrypoints.openai.serving_video_output_stream import OmniStreamingVideoOutputHandler
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -27,6 +28,12 @@ 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 _receive_video_chunk(ws) -> tuple[dict, bytes]:
metadata = ws.receive_json()
assert metadata["type"] == "video.chunk_metadata"
return metadata, ws.receive_bytes()
def _build_test_app(
*,
mocker: MockerFixture,
@@ -37,6 +44,7 @@ def _build_test_app(
) -> tuple[FastAPI, OmniStreamingVideoOutputHandler, MagicMock]:
engine_client = mocker.MagicMock()
engine_client.abort = mocker.AsyncMock()
engine_client.submit_interaction_async = mocker.AsyncMock()
engine_client.default_sampling_params_list = [OmniDiffusionSamplingParams()]
engine_client.stage_configs = [SimpleNamespace(stage_type="diffusion")]
engine_client.generate = mock_generate
@@ -126,8 +134,13 @@ class TestStreamingVideoOutputWebSocket:
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"
metadata, chunk = _receive_video_chunk(ws)
assert metadata["transport_chunk_index"] == 0
assert metadata["kind"] == "media"
assert chunk == b"mp4-chunk-0"
metadata, chunk = _receive_video_chunk(ws)
assert metadata["transport_chunk_index"] == 1
assert chunk == b"mp4-chunk-1"
done = ws.receive_json()
assert done["type"] == "session.done"
@@ -138,6 +151,48 @@ class TestStreamingVideoOutputWebSocket:
engine_client.streaming_encoder_factory.assert_called_once()
assert engine_client.streaming_encoder_factory.call_args.kwargs["output_format"] == "m4s"
def test_streaming_session_accepts_initial_image_reference(self, mocker: MockerFixture):
"""session.start forwards image_reference as prompt multi_modal_data.image."""
captured_prompts: list[dict[str, object]] = []
async def mock_generate(*_args, **kwargs):
captured_prompts.append(kwargs["prompt"])
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,
)
synth_image = generate_synthetic_image(4, 4)
image_url = f"data:image/jpeg;base64,{synth_image['base64']}"
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime/video") as ws:
ws.send_json(
{
"type": "session.start",
"prompt": "animate this image",
"width": 4,
"height": 4,
"image_reference": {"image_url": image_url},
}
)
assert ws.receive_json()["type"] == "video.start"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0"
assert ws.receive_json()["type"] == "session.done"
prompt = captured_prompts[0]
image = prompt["multi_modal_data"]["image"] # type: ignore[index]
assert image.size == (4, 4)
engine_client.abort.assert_not_called()
def test_streaming_session_rejects_unknown_format(self, mocker: MockerFixture):
async def mock_generate(*_args, **_kwargs):
if False:
@@ -158,6 +213,33 @@ class TestStreamingVideoOutputWebSocket:
engine_client.abort.assert_not_called()
def test_first_message_must_be_session_start(self, mocker: MockerFixture):
"""Control messages sent before session.start are rejected at the handshake.
This test can practically ensure ``session.interaction`` before ``session.start`` is rejected,
but the implementation is more generic to also guard against other control messages.
"""
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.interaction", "interaction": {"event": {"prompt": "too early"}}})
err = ws.receive_json()
assert err["type"] == "error"
assert err["message"] == "Expected session.start, got: session.interaction"
engine_client.abort.assert_not_called()
engine_client.submit_interaction_async.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."""
@@ -180,8 +262,13 @@ class TestStreamingVideoOutputWebSocket:
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"
metadata, chunk = _receive_video_chunk(ws)
assert metadata["kind"] == "media"
assert chunk == b"mp4-chunk-0"
metadata, chunk = _receive_video_chunk(ws)
assert metadata["kind"] == "trailer"
assert metadata["generation_chunk_index"] is None
assert chunk == b"mp4-trailer"
done = ws.receive_json()
assert done["type"] == "session.done"
@@ -213,7 +300,7 @@ class TestStreamingVideoOutputWebSocket:
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"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0"
err = ws.receive_json()
assert err["type"] == "error"
assert error_message in err["message"]
@@ -259,6 +346,7 @@ def _build_handler_for_async_tests(
) -> tuple[OmniStreamingVideoOutputHandler, MagicMock, _MockVideoOutputWebSocket]:
engine_client = mocker.MagicMock()
engine_client.abort = mocker.AsyncMock()
engine_client.submit_interaction_async = mocker.AsyncMock()
engine_client.default_sampling_params_list = [OmniDiffusionSamplingParams()]
engine_client.stage_configs = [SimpleNamespace(stage_type="diffusion")]
engine_client.generate = mock_generate
@@ -340,8 +428,8 @@ class TestStreamingVideoOutputStallTimeout:
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"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1"
done = ws.receive_json()
assert done["type"] == "session.done"
assert done["stopped"] is False
@@ -444,3 +532,374 @@ class TestStreamingVideoOutputStallTimeout:
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()
class TestStreamingVideoOutputPromptUpdate:
"""session.interaction control messages during streaming."""
def test_prompt_update_accepted_during_streaming(self, mocker: MockerFixture):
"""A valid interaction during generation is forwarded to the engine and acknowledged."""
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,
)
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime/video") as ws:
ws.send_json({"type": "session.start", "prompt": "initial scene"})
start = ws.receive_json()
assert start["type"] == "video.start"
request_id = start["request_id"]
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0"
ws.send_json(
{
"type": "session.interaction",
"interaction": {
"event_id": "ui-update-42",
"event": {"prompt": "new scene"},
},
}
)
queued = ws.receive_json()
assert queued["type"] == "session.interaction.queued"
assert queued["request_id"] == request_id
assert queued["event_id"] == "ui-update-42"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1"
done = ws.receive_json()
assert done["type"] == "session.done"
assert done["stopped"] is False
engine_client.submit_interaction_async.assert_awaited_once_with(
request_id,
interaction={"event_id": "ui-update-42", "event": {"prompt": "new scene"}},
)
def test_prompt_update_rejects_duplicate_event_id(self, mocker: MockerFixture):
"""Any repeated event_id is rejected at the session API boundary."""
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,
)
interaction = {
"type": "session.interaction",
"interaction": {
"event_id": "ui-update-42",
"event": {"prompt": "new scene"},
},
}
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime/video") as ws:
ws.send_json({"type": "session.start", "prompt": "initial scene"})
request_id = ws.receive_json()["request_id"]
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0"
ws.send_json(interaction)
queued = ws.receive_json()
assert queued["type"] == "session.interaction.queued"
ws.send_json(interaction)
err = ws.receive_json()
assert err["type"] == "error"
assert err["code"] == "event_id_conflict"
assert err["request_id"] == request_id
assert err["event_id"] == "ui-update-42"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1"
assert ws.receive_json()["type"] == "session.done"
engine_client.submit_interaction_async.assert_awaited_once_with(
request_id,
interaction={"event_id": "ui-update-42", "event": {"prompt": "new scene"}},
)
def test_prompt_update_forwards_transition_chunks(self, mocker: MockerFixture):
"""transition_chunks is passed through to the engine client."""
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,
)
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime/video") as ws:
ws.send_json({"type": "session.start", "prompt": "initial"})
request_id = ws.receive_json()["request_id"]
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0"
ws.send_json(
{
"type": "session.interaction",
"interaction": {
"event_id": "ui-update-42",
"event": {"prompt": "fade to sunset"},
"transition_chunks": 5,
},
}
)
assert ws.receive_json()["type"] == "session.interaction.queued"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1"
assert ws.receive_json()["type"] == "session.done"
engine_client.submit_interaction_async.assert_awaited_once_with(
request_id,
interaction={
"event_id": "ui-update-42",
"event": {"prompt": "fade to sunset"},
"transition_chunks": 5,
},
)
def test_prompt_update_rejects_empty_prompt(self, mocker: MockerFixture):
"""An empty prompt is rejected before calling the engine."""
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,
)
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime/video") as ws:
ws.send_json({"type": "session.start", "prompt": "initial"})
assert ws.receive_json()["type"] == "video.start"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0"
ws.send_json({"type": "session.interaction", "interaction": {"event": {"prompt": " "}}})
err = ws.receive_json()
assert err["type"] == "error"
assert "non-empty event.prompt" in err["message"]
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1"
assert ws.receive_json()["type"] == "session.done"
engine_client.submit_interaction_async.assert_not_called()
@pytest.mark.parametrize(
("transition_chunks"),
["not-an-int", 2.9, True, -1],
)
def test_prompt_update_rejects_invalid_transition_chunks(
self,
mocker: MockerFixture,
transition_chunks: object,
):
"""Non-integral or negative transition_chunks returns an error."""
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,
)
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime/video") as ws:
ws.send_json({"type": "session.start", "prompt": "initial"})
assert ws.receive_json()["type"] == "video.start"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0"
ws.send_json(
{
"type": "session.interaction",
"interaction": {
"event": {"prompt": "new scene"},
"transition_chunks": transition_chunks,
},
}
)
err = ws.receive_json()
assert err["type"] == "error"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1"
assert ws.receive_json()["type"] == "session.done"
engine_client.submit_interaction_async.assert_not_called()
def test_prompt_update_engine_failure_returns_error(self, mocker: MockerFixture):
"""Engine failures during interaction are surfaced to the client."""
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,
)
engine_client.submit_interaction_async.side_effect = RuntimeError("engine unavailable")
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime/video") as ws:
ws.send_json({"type": "session.start", "prompt": "initial"})
assert ws.receive_json()["type"] == "video.start"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-0"
ws.send_json(
{
"type": "session.interaction",
"interaction": {
"event_id": "ui-update-42",
"event": {"prompt": "new scene"},
},
}
)
err = ws.receive_json()
assert err["type"] == "error"
assert "Failed to apply interaction" in err["message"]
assert err["code"] == "interaction_failed"
assert err["event_id"] == "ui-update-42"
assert _receive_video_chunk(ws)[1] == b"mp4-chunk-1"
assert ws.receive_json()["type"] == "session.done"
engine_client.submit_interaction_async.assert_awaited_once()
@pytest.mark.asyncio
async def test_prompt_update_accepted_via_async_handler(self, mocker: MockerFixture):
"""interaction acknowledgement is sent while generation is in progress."""
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,
)
async def _send_interaction_after_delay() -> None:
await asyncio.sleep(0.05)
ws.enqueue(
json.dumps(
{
"type": "session.interaction",
"interaction": {
"event_id": "ui-update-42",
"event": {"prompt": "mid-stream update"},
"transition_chunks": 2,
},
}
)
)
prompt_task = asyncio.create_task(_send_interaction_after_delay())
session_task = asyncio.create_task(handler.handle_session(ws)) # type: ignore[arg-type]
await asyncio.sleep(0.15)
release.set()
await session_task
await prompt_task
start = next(m for m in ws.sent_json if m.get("type") == "video.start")
request_id = start["request_id"]
assert any(m.get("type") == "session.interaction.queued" for m in ws.sent_json)
engine_client.submit_interaction_async.assert_awaited_once_with(
request_id,
interaction={
"event_id": "ui-update-42",
"event": {"prompt": "mid-stream update"},
"transition_chunks": 2,
},
)
@@ -0,0 +1,140 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Entrypoint contract tests for ``AsyncOmni.submit_interaction_async``."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.client_request_state import ClientRequestState
from vllm_omni.inputs.data import OmniInteractionPrompt
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_async_omni(*, num_stages: int = 1, stage_type: str = "diffusion") -> AsyncOmni:
omni = object.__new__(AsyncOmni)
omni.log_stats = False
omni.request_states = {
"external-abc-uuid-1": ClientRequestState(
request_id="external-abc-uuid-1",
external_request_id="external-abc",
queue=AsyncMock(),
),
"external-abc-uuid-2": ClientRequestState(
request_id="external-abc-uuid-2",
external_request_id="external-abc",
queue=AsyncMock(),
),
"other-req-uuid": ClientRequestState(
request_id="other-req-uuid",
external_request_id="other-req",
queue=AsyncMock(),
),
}
omni.engine = SimpleNamespace( # pyright: ignore[reportAttributeAccessIssue]
num_stages=num_stages,
get_stage_metadata=lambda stage_id: SimpleNamespace(stage_type=stage_type),
submit_interaction_async=AsyncMock(),
)
return omni
def _prompt_interaction(prompt: str = "new prompt", transition_chunks: int | None = None) -> OmniInteractionPrompt:
interaction: OmniInteractionPrompt = {"event_id": "ui-update-1", "event": {"prompt": prompt}}
if transition_chunks is not None:
interaction["transition_chunks"] = transition_chunks
return interaction
@pytest.mark.asyncio
async def test_submit_interaction_async_maps_external_to_internal_id() -> None:
omni = _make_async_omni()
with pytest.raises(ValueError, match="exactly one active request"):
await omni.submit_interaction_async("external-abc", interaction=_prompt_interaction(transition_chunks=2))
omni.request_states.pop("external-abc-uuid-2")
await omni.submit_interaction_async(
"external-abc",
interaction=_prompt_interaction(transition_chunks=2),
)
omni.engine.submit_interaction_async.assert_awaited_once_with( # pyright: ignore[reportAttributeAccessIssue]
"external-abc-uuid-1",
interaction=_prompt_interaction(transition_chunks=2),
)
@pytest.mark.asyncio
async def test_submit_interaction_async_passes_missing_transition_chunks() -> None:
omni = _make_async_omni()
omni.request_states.pop("external-abc-uuid-2")
await omni.submit_interaction_async("external-abc", interaction=_prompt_interaction())
omni.engine.submit_interaction_async.assert_awaited_once_with( # pyright: ignore[reportAttributeAccessIssue]
"external-abc-uuid-1",
interaction=_prompt_interaction(),
)
@pytest.mark.asyncio
async def test_submit_interaction_async_rejects_empty_prompt() -> None:
omni = _make_async_omni()
omni.request_states.pop("external-abc-uuid-2")
with pytest.raises(ValueError, match="prompt must be non-empty"):
await omni.submit_interaction_async("external-abc", interaction=_prompt_interaction(""))
@pytest.mark.asyncio
async def test_submit_interaction_async_rejects_negative_transition_chunks() -> None:
omni = _make_async_omni()
omni.request_states.pop("external-abc-uuid-2")
with pytest.raises(ValueError, match="transition_chunks must be >= 0"):
await omni.submit_interaction_async(
"external-abc",
interaction=_prompt_interaction(transition_chunks=-1),
)
@pytest.mark.asyncio
async def test_submit_interaction_async_rejects_inactive_request() -> None:
omni = _make_async_omni()
omni.request_states.clear()
with pytest.raises(ValueError, match="No active request"):
await omni.submit_interaction_async("missing", interaction=_prompt_interaction(transition_chunks=2))
@pytest.mark.asyncio
async def test_submit_interaction_async_rejects_non_diffusion() -> None:
omni = _make_async_omni(stage_type="llm")
omni.request_states = {
"req-uuid": ClientRequestState(
request_id="req-uuid",
external_request_id="req",
queue=AsyncMock(),
),
}
with pytest.raises(ValueError, match="requires a diffusion stage"):
await omni.submit_interaction_async("req", interaction=_prompt_interaction(transition_chunks=2))
@pytest.mark.asyncio
async def test_submit_interaction_async_forwards_unsupported_dict_shape_to_engine() -> None:
omni = _make_async_omni()
omni.request_states.pop("external-abc-uuid-2")
payload: OmniInteractionPrompt = { # pyright: ignore[reportAssignmentType] # deliberately wrong
"event": {"multi_modal_data": {"unsupported_modality": {"type": "pose"}}},
"transition_chunks": 2,
}
await omni.submit_interaction_async(
"external-abc",
interaction=payload,
)
omni.engine.submit_interaction_async.assert_awaited_once_with( # pyright: ignore[reportAttributeAccessIssue]
"external-abc-uuid-1",
interaction=payload,
)
+3
View File
@@ -1274,6 +1274,9 @@ class DiffusionOutput:
finished: bool = True
chunk_index: int = 0
total_chunks: int = 1
started_event_ids: list[str] = field(default_factory=list)
active_event_ids: list[str] = field(default_factory=list)
completed_event_ids: list[str] = field(default_factory=list)
# logged duration of stages
stage_durations: dict[str, float] = field(default_factory=dict)
@@ -21,7 +21,7 @@ from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.engine.stage_client import StageClientBase
from vllm_omni.engine.stage_init_utils import StageMetadata
from vllm_omni.errors import client_error_metadata
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniInteractionPrompt
from vllm_omni.outputs import OmniRequestOutput
if TYPE_CHECKING:
@@ -179,6 +179,25 @@ class InlineStageDiffusionClient(StageClientBase):
task.cancel()
self._engine.abort(rid)
async def submit_interaction_async(
self,
request_id: str,
interaction: OmniInteractionPrompt,
timeout: float | None = None,
) -> Any:
"""Apply a midway interaction to an active streaming request."""
logger.debug(
"[InlineStageDiffusionClient] stage-%s [rep-%s] interaction: %s",
self.stage_id,
self.replica_id,
request_id,
)
return await self.collective_rpc_async(
"submit_interaction",
timeout=timeout,
args=(request_id, interaction),
)
async def collective_rpc_async(
self,
method: str,
@@ -29,6 +29,7 @@ from vllm_omni.diffusion.models.helios.scheduling_helios import HeliosScheduler
from vllm_omni.diffusion.models.interface import SupportsComponentDiscovery
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.prompt_update import PromptUpdateMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.platforms import current_omni_platform
@@ -155,7 +156,12 @@ def get_helios_pre_process_func(
class HeliosPipeline(
nn.Module, CFGParallelMixin, ProgressBarMixin, DiffusionPipelineProfilerMixin, SupportsComponentDiscovery
nn.Module,
CFGParallelMixin,
ProgressBarMixin,
DiffusionPipelineProfilerMixin,
PromptUpdateMixin,
SupportsComponentDiscovery,
):
"""Helios text-to-video / image-to-video / video-to-video pipeline for vllm-omni.
@@ -894,9 +900,12 @@ class HeliosPipeline(
output = current_latents if extra["output_type"] == "latent" else current_video
completed_chunk_index = state.chunk_index
prompt_update_metadata = state.extra.pop("prompt_update_chunk_metadata", {})
state.chunk_index += 1
finished = state.request_denoise_completed
if not finished:
# Apply queued/advancing prompt updates before preparing the next chunk.
self._apply_prompt_update_at_chunk_boundary(state)
self._prepare_next_chunk(state)
else:
self._current_timestep = None
@@ -908,6 +917,9 @@ class HeliosPipeline(
stage_durations=self.stage_durations if hasattr(self, "stage_durations") else {},
chunk_index=completed_chunk_index,
total_chunks=state.total_chunks,
started_event_ids=prompt_update_metadata.get("started_event_ids", []),
active_event_ids=prompt_update_metadata.get("active_event_ids", []),
completed_event_ids=prompt_update_metadata.get("completed_event_ids", []),
finished=finished,
)
@@ -1585,12 +1597,13 @@ class HeliosPipeline(
negative_prompt: str | list[str] | None = None,
do_classifier_free_guidance: bool = True,
num_videos_per_prompt: int = 1,
max_sequence_length: int = 226,
max_sequence_length: int | None = None,
device: torch.device | None = None,
dtype: torch.dtype | None = None,
):
device = device or self.device
dtype = dtype or self.text_encoder.dtype
max_sequence_length = max_sequence_length or 226
prompt = [prompt] if isinstance(prompt, str) else prompt
prompt_clean = [self._prompt_clean(p) for p in prompt]
+32 -1
View File
@@ -13,6 +13,8 @@ from typing import (
)
if TYPE_CHECKING:
from collections.abc import Sequence
import torch
from vllm_omni.diffusion.data import DiffusionOutput
@@ -58,7 +60,9 @@ class SupportsStepExecution(Protocol):
"""Prepare request-level inputs and return initialized state."""
...
def denoise_step(self, input_batch: InputBatch, **kwargs: Any) -> torch.Tensor | None:
def denoise_step(
self, input_batch: InputBatch, *, states: Sequence[StepRequestState] | None = None, **kwargs: Any
) -> torch.Tensor | None:
"""Run one denoise forward on the runner-assembled batch."""
...
@@ -100,3 +104,30 @@ def supports_step_execution(pipeline: object) -> bool:
"""Return whether `pipeline` implements :class:`SupportsStepExecution`."""
return isinstance(pipeline, SupportsStepExecution)
@runtime_checkable
class SupportsPromptUpdate(Protocol):
"""Optional protocol for pipelines that support midway prompt updates.
Pipelines typically implement this via
:class:`~vllm_omni.diffusion.prompt_update.PromptUpdateMixin`.
"""
supports_prompt_update: ClassVar[bool] = True
def prepare_prompt_update(
self,
state: StepRequestState,
prompt: str,
event_id: str,
transition_chunks: int | None = None,
) -> None:
"""Encode and queue a prompt update on request-local state."""
...
def supports_prompt_update(pipeline: object) -> bool:
"""Return whether ``pipeline`` implements :class:`SupportsPromptUpdate`."""
return isinstance(pipeline, SupportsPromptUpdate)
+29 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import TypeGuard
from typing import TypeGuard, cast
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.io_support import supports_audio_output
@@ -150,6 +150,13 @@ def format_diffusion_outputs(
model_cls = DiffusionModelRegistry._try_load_model_cls(od_config.model_class_name)
audio_sample_rate = getattr(model_cls, "audio_sample_rate", None)
stream_metadata = _stream_metadata_from_diffusion_output(diffusion_output)
if stream_metadata:
existing_stream = postprocess_output.metadata.get("stream")
merged_stream = dict(existing_stream) if isinstance(existing_stream, Mapping) else {}
merged_stream.update(stream_metadata)
postprocess_output.metadata["stream"] = merged_stream
return _format_single_prompt_output(
request=request,
prompt=request.prompt,
@@ -164,6 +171,27 @@ def format_diffusion_outputs(
)
def _stream_metadata_from_diffusion_output(diffusion_output: DiffusionOutput) -> DiffusionMetadata | None:
has_stream_data = (
diffusion_output.total_chunks > 1
or not diffusion_output.finished
or bool(diffusion_output.started_event_ids)
or bool(diffusion_output.active_event_ids)
or bool(diffusion_output.completed_event_ids)
)
if not has_stream_data:
return None
return cast(
DiffusionMetadata,
{
"generation_chunk_index": diffusion_output.chunk_index,
"started_event_ids": diffusion_output.started_event_ids,
"active_event_ids": diffusion_output.active_event_ids,
"completed_event_ids": diffusion_output.completed_event_ids,
},
)
def _ensure_list(outputs: DiffusionPayloadValue) -> list[DiffusionPayloadValue]:
if isinstance(outputs, list):
return outputs
+245
View File
@@ -0,0 +1,245 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Midway prompt updates for chunked diffusion step execution.
``PromptUpdateMixin`` exposes
- a pipeline interface for the external to queue a prompt update,
- a pipeline internal method to apply prompt transitions at chunk boundaries.
``prompt_update_versions``: a helper for ``InputBatch`` cache invalidation.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from threading import Lock
from typing import ClassVar, TypedDict, cast
import torch
from vllm_omni.diffusion.worker.utils import StepRequestState
DEFAULT_TRANSITION_CHUNKS = 3
logger = logging.getLogger(__name__)
class PromptUpdateExtra(TypedDict, total=False):
"""A "protocol" for ``StepRequestState.extra`` keys used by the prompt-update feature."""
pending_prompt_update: _PendingPromptUpdate
prompt_update_state: _PromptUpdateState
prompt_update_chunk_metadata: _PromptUpdateChunkMetadata
prompt_update_version: int # Bumped when prompt_embeds change; invalidates InputBatch cache.
# The lock for storing/updating pending prompt updates.
# Saved in state.extra to be naturally cleaned up with the request state.
# As PromptUpdateMixin (the pipeline) currently lacks an obvious request-finish hook
prompt_update_lock: Lock
def prompt_update_versions(states: Sequence[StepRequestState]) -> tuple[int, ...]:
"""Return per-request prompt-update versions for batch cache comparison.
To be used by ``InputBatch`` or other external to determine if the cached batch is still valid.
"""
return tuple(int(cast(PromptUpdateExtra, state.extra).get("prompt_update_version", 0)) for state in states)
class PromptUpdateMixin:
"""Mixin for chunked streaming pipelines that support midway prompt updates.
Implements the behavior expected by :class:`~vllm_omni.diffusion.models.interface.SupportsPromptUpdate`.
All per-request transition state lives on ``StepRequestState.extra``; this mixin itself is stateless.
"""
supports_prompt_update: ClassVar[bool] = True
@staticmethod
def _prompt_update_lock(extra: PromptUpdateExtra) -> Lock:
return extra.setdefault("prompt_update_lock", Lock())
def prepare_prompt_update(
self,
state: StepRequestState,
prompt: str,
event_id: str,
transition_chunks: int | None = None,
) -> None:
"""Encode and queue a prompt update to apply at the next chunk boundary.
It does not actually apply the prompt transition.
This is an extended interface for the pipeline, to be called by the runner.
"""
if not prompt:
raise ValueError("prompt must be non-empty")
if not event_id:
raise ValueError("event_id must be non-empty")
if state.prompt_embeds is None:
raise ValueError(
f"prompt_update is not allowed before initial generation has started (request_id={state.request_id!r})"
)
duration = DEFAULT_TRANSITION_CHUNKS if transition_chunks is None else transition_chunks
if duration < 0:
raise ValueError("transition_chunks must be >= 0")
target_prompt_embeds, _ = self.encode_prompt(
prompt=prompt,
negative_prompt=None,
do_classifier_free_guidance=False,
num_videos_per_prompt=state.sampling.num_outputs_per_prompt,
max_sequence_length=state.sampling.max_sequence_length,
device=self.device,
dtype=self.transformer.dtype,
)
extra = cast(PromptUpdateExtra, state.extra)
with self._prompt_update_lock(extra):
extra["pending_prompt_update"] = {
"event_id": event_id,
"prompt": prompt,
"target_prompt_embeds": target_prompt_embeds,
"transition_chunks": duration,
}
def _apply_prompt_update_at_chunk_boundary(self, state: StepRequestState) -> None:
"""Advance or start prompt interpolation before the next chunk."""
extra = cast(PromptUpdateExtra, state.extra)
update_state: _PromptUpdateState | None = extra.get("prompt_update_state")
with self._prompt_update_lock(extra):
pending = extra.pop("pending_prompt_update", None)
embeds_changed = False
next_chunk_index = state.chunk_index
started_event_ids: list[str] = []
active_event_ids: list[str] = []
completed_event_ids: list[str] = []
# If current transition is not complete, advance it.
# After completion, leave prompt_update_state in place (so a later pending
# update can abort/overwrite), but do not keep bumping the version.
if update_state is not None:
in_transition = (
update_state.transition_chunks > 0
and update_state.elapsed_transition_chunks < update_state.transition_chunks
)
if in_transition:
update_state.advance_transition()
state.prompt_embeds = update_state.blended_prompt_embeds()
embeds_changed = True
active_event_ids.append(update_state.event_id)
if update_state.elapsed_transition_chunks >= update_state.transition_chunks:
state.prompt_embeds = update_state.target_prompt_embeds
update_state.source_prompt_embeds = update_state.target_prompt_embeds
completed_event_ids.append(update_state.event_id)
if update_state.target_prompt is not None:
logger.debug(
"prompt_update transition complete request_id=%s next_chunk_index=%d prompt=%.20s...",
state.request_id,
next_chunk_index,
update_state.target_prompt,
)
# If a new prompt update is pending, start a new transition.
if pending is not None:
if state.prompt_embeds is None:
raise RuntimeError(
"internal error: trying to apply a pending prompt update but "
f"current prompt_embeds is None (request_id={state.request_id!r})"
)
source = state.prompt_embeds.detach().clone()
target = pending["target_prompt_embeds"]
duration = int(pending["transition_chunks"])
prompt = str(pending.get("prompt", ""))
event_id = pending.get("event_id")
update_state = _PromptUpdateState(
source_prompt_embeds=source,
target_prompt_embeds=target,
transition_chunks=duration,
event_id=event_id,
target_prompt=prompt,
)
extra["prompt_update_state"] = update_state
started_event_ids.append(event_id)
active_event_ids.append(event_id)
if duration <= 0:
# A hard/immediate transition
state.prompt_embeds = target
completed_event_ids.append(event_id)
logger.debug(
"prompt_update sharp transition request_id=%s next_chunk_index=%d prompt=%.20s...",
state.request_id,
next_chunk_index,
prompt,
)
else:
# A transition that really takes some time
update_state.advance_transition()
state.prompt_embeds = update_state.blended_prompt_embeds()
if update_state.elapsed_transition_chunks >= update_state.transition_chunks:
state.prompt_embeds = update_state.target_prompt_embeds
update_state.source_prompt_embeds = update_state.target_prompt_embeds
completed_event_ids.append(event_id)
logger.debug(
"prompt_update transition start request_id=%s next_chunk_index=%d prompt=%.20s...",
state.request_id,
next_chunk_index,
prompt,
)
embeds_changed = True
# Indicate that prompt embeddings have changed---clear input batch cache.
if embeds_changed:
new_version = int(extra.get("prompt_update_version", 0)) + 1
extra["prompt_update_version"] = new_version
extra["prompt_update_chunk_metadata"] = {
"started_event_ids": started_event_ids,
"active_event_ids": active_event_ids,
"completed_event_ids": completed_event_ids,
}
@dataclass
class _PromptUpdateState:
"""Per-request prompt interpolation stored in ``state.extra["prompt_update_state"]``."""
source_prompt_embeds: torch.Tensor
target_prompt_embeds: torch.Tensor
transition_chunks: int
event_id: str
elapsed_transition_chunks: int = 0
target_prompt: str | None = None
def blended_prompt_embeds(self) -> torch.Tensor:
alpha = self._current_alpha()
if alpha >= 1.0:
return self.target_prompt_embeds
return (1.0 - alpha) * self.source_prompt_embeds + alpha * self.target_prompt_embeds
def advance_transition(self) -> None:
if self.transition_chunks <= 0:
self.source_prompt_embeds = self.target_prompt_embeds
return
self.elapsed_transition_chunks += 1
if self.elapsed_transition_chunks >= self.transition_chunks:
self.source_prompt_embeds = self.target_prompt_embeds
self.elapsed_transition_chunks = self.transition_chunks
def _current_alpha(self) -> float:
if self.transition_chunks <= 0:
return 1.0
return min(1.0, self.elapsed_transition_chunks / self.transition_chunks)
class _PendingPromptUpdate(TypedDict):
event_id: str
prompt: str
target_prompt_embeds: torch.Tensor
transition_chunks: int
class _PromptUpdateChunkMetadata(TypedDict):
started_event_ids: list[str]
active_event_ids: list[str]
completed_event_ids: list[str]
+14 -1
View File
@@ -34,7 +34,7 @@ from vllm_omni.outputs import OmniRequestOutput
if TYPE_CHECKING:
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniInteractionPrompt, OmniPromptType
logger = init_logger(__name__)
_MISSING_RPC_RESULT = object()
@@ -395,6 +395,19 @@ class StageDiffusionClient(StageClientBase):
)
)
async def submit_interaction_async(
self,
request_id: str,
interaction: OmniInteractionPrompt,
timeout: float | None = None,
) -> Any:
"""Apply a midway interaction to an active streaming request."""
return await self.collective_rpc_async(
"submit_interaction",
timeout=timeout,
args=(request_id, interaction),
)
async def collective_rpc_async(
self,
method: str,
@@ -14,7 +14,7 @@ import copy
import time
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
from typing import Any
from typing import TYPE_CHECKING, Any, cast
import torch
from torch.profiler import record_function
@@ -32,7 +32,7 @@ from vllm_omni.diffusion.compile import regionally_compile
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.forward_context import set_forward_context
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.models.interface import supports_step_execution
from vllm_omni.diffusion.models.interface import SupportsPromptUpdate, supports_prompt_update, supports_step_execution
from vllm_omni.diffusion.offloader import get_offload_backend
from vllm_omni.diffusion.registry import _NO_CACHE_ACCELERATION
from vllm_omni.diffusion.request import OmniDiffusionRequest
@@ -53,6 +53,9 @@ from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin
if TYPE_CHECKING:
from vllm_omni.inputs.data import OmniInteractionPrompt
logger = init_logger(__name__)
@@ -803,3 +806,48 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
self._update_states_after(states, input_batch, pipeline_interrupted)
return BatchRunnerOutput.from_list(runner_output_list)
def submit_interaction(
self,
request_id: str,
interaction: OmniInteractionPrompt,
) -> None:
"""Route a midway interaction to the matching active stepwise request feature."""
assert self.pipeline is not None, "Model not loaded. Call load_model() first."
if not self.od_config.streaming_output:
raise ValueError("submit_interaction requires streaming_output=True")
if not self._supports_step_mode():
raise ValueError("submit_interaction requires step execution support")
event = interaction.get("event")
if isinstance(event, dict) and "prompt" in event and "multi_modal_data" not in event:
# Is a prompt update interaction.
self._submit_prompt_update_interaction(request_id, interaction)
return
raise NotImplementedError(
"Only text-only prompt update interactions with 'event.prompt' and optional "
"'transition_chunks' are supported in this release"
)
def _submit_prompt_update_interaction(
self,
request_id: str,
interaction: OmniInteractionPrompt,
) -> None:
"""Queue a prompt-update interaction for an active stepwise request."""
if not supports_prompt_update(self.pipeline):
raise ValueError(f"prompt_update is not supported by pipeline {self.od_config.model_class_name!r}")
state = self.state_cache.get(request_id)
if state is None:
raise ValueError(f"No active request state for prompt_update: {request_id!r}")
event = cast(dict[str, Any], interaction.get("event"))
prompt = event["prompt"]
transition_chunks = interaction.get("transition_chunks")
event_id = interaction.get("event_id")
if not isinstance(event_id, str) or not event_id:
raise ValueError("event_id must be non-empty")
pipeline = cast(SupportsPromptUpdate, self.pipeline)
pipeline.prepare_prompt_update(state, prompt, event_id, transition_chunks)
@@ -50,6 +50,7 @@ from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput, KVPref
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
from vllm_omni.diffusion.worker.utils import BaseRunnerOutput, BatchRunnerOutput
from vllm_omni.engine.stage_init_utils import set_death_signal
from vllm_omni.inputs.data import OmniInteractionPrompt
from vllm_omni.lora.request import LoRARequest
from vllm_omni.platforms import current_omni_platform
from vllm_omni.profiler import OmniTorchProfilerWrapper, create_omni_profiler
@@ -511,6 +512,15 @@ class DiffusionWorker:
# for the frontend server yet.
return self.lora_manager.add_adapter(lora_request)
def submit_interaction(
self,
request_id: str,
interaction: OmniInteractionPrompt,
) -> None:
"""Apply a midway interaction to an active stepwise request."""
assert self.model_runner is not None, "Model runner not initialized"
self.model_runner.submit_interaction(request_id, interaction)
def list_loras(self) -> list[int]:
return self.lora_manager.list_adapters()
+14 -2
View File
@@ -17,6 +17,7 @@ from dataclasses import dataclass, field
import numpy as np
import torch
from vllm_omni.diffusion.prompt_update import prompt_update_versions
from vllm_omni.diffusion.worker.utils import StepRequestState
@@ -535,12 +536,17 @@ def _same_composition(
cached_batch: InputBatch | None,
request_ids: list[str],
idx_mapping_np: np.ndarray,
states: Sequence[StepRequestState],
) -> bool:
if cached_batch is None:
return False
if cached_batch.request_ids != request_ids:
return False
return np.array_equal(cached_batch.idx_mapping_np, idx_mapping_np)
if not np.array_equal(cached_batch.idx_mapping_np, idx_mapping_np):
return False
# Midway prompt updates (typically for video generation) can change prompt_embeds without changing ids/mapping.
# In this case, each request state manages the embedding's "version". Use it to determine if cache is still valid.
return cached_batch._prompt_update_versions == prompt_update_versions(states)
def _scatter_batch_tensor_by_mapping(
@@ -615,6 +621,10 @@ class InputBatch:
negative_txt_seq_lens: list[int] | None = None
states: Sequence[StepRequestState] = field(default_factory=tuple)
# For midway prompt updates (typically for video generation) that changes embeddings without changing ids,
# Keep a snapshot of the current per-request versions of prompt embeddings for later runtime comparison
_prompt_update_versions: tuple[int, ...] = ()
def __post_init__(self) -> None:
if len(self.request_ids) != int(self.idx_mapping.numel()):
raise ValueError("`request_ids` and `idx_mapping` must have the same length.")
@@ -653,6 +663,7 @@ class InputBatch:
self.img_shapes = _prepare_img_shapes(states)
self.txt_seq_lens = _prepare_seq_lens(states, "txt_seq_lens")
self.negative_txt_seq_lens = _prepare_seq_lens(states, "negative_txt_seq_lens")
self._prompt_update_versions = prompt_update_versions(states)
def _repack_dynamic_fields(
self,
@@ -693,7 +704,7 @@ class InputBatch:
selected_states, idx_mapping, idx_mapping_np = _select_states(states, idx_mapping)
request_ids = _prepare_request_ids(selected_states)
if _same_composition(cached_batch, request_ids, idx_mapping_np):
if _same_composition(cached_batch, request_ids, idx_mapping_np, selected_states):
assert cached_batch is not None
cached_batch._repack_dynamic_fields(selected_states)
return cached_batch
@@ -733,6 +744,7 @@ class InputBatch:
"negative_txt_seq_lens",
),
states=tuple(selected_states),
_prompt_update_versions=prompt_update_versions(selected_states),
)
@@ -48,6 +48,9 @@ def split_diffusion_output_by_request(
peak_memory_mb=result.peak_memory_mb,
chunk_index=result.chunk_index,
total_chunks=result.total_chunks,
started_event_ids=result.started_event_ids,
active_event_ids=result.active_event_ids,
completed_event_ids=result.completed_event_ids,
)
for idx in range(req.num_reqs)
]
+26 -1
View File
@@ -55,6 +55,7 @@ from vllm_omni.engine.messages import (
CollectiveRPCResultMessage,
EngineQueueMessage,
ErrorMessage,
InteractionMessage,
StageSubmissionMessage,
)
from vllm_omni.engine.orchestrator import Orchestrator
@@ -71,7 +72,7 @@ from vllm_omni.entrypoints.utils import (
load_and_resolve_stage_configs,
parse_stage_overrides,
)
from vllm_omni.inputs.data import OmniSamplingParams
from vllm_omni.inputs.data import OmniInteractionPrompt, OmniSamplingParams
from vllm_omni.metrics.prometheus import OmniRequestCounter
logger = init_logger(__name__)
@@ -1703,6 +1704,30 @@ class AsyncOmniEngine:
"""Async abort API."""
self.abort(request_ids)
def submit_interaction(
self,
request_id: str,
interaction: OmniInteractionPrompt,
) -> None:
"""Send an interaction control message to the Orchestrator."""
if self.request_queue is None:
raise RuntimeError("request_queue is not initialized")
self.request_queue.sync_q.put_nowait(
InteractionMessage(
request_id=request_id,
interaction=interaction,
)
)
async def submit_interaction_async(
self,
request_id: str,
interaction: OmniInteractionPrompt,
) -> None:
"""Async interaction API."""
self.submit_interaction(request_id, interaction)
def collective_rpc(
self,
method: str,
+8 -1
View File
@@ -6,7 +6,7 @@ import msgspec
from vllm.inputs import PromptType
from vllm.v1.engine import EngineCoreRequest
from vllm_omni.inputs.data import OmniSamplingParams
from vllm_omni.inputs.data import OmniInteractionPrompt, OmniSamplingParams
from vllm_omni.metrics.stats import StageRequestStats as StageRequestMetrics
from vllm_omni.outputs import OmniRequestOutput
@@ -44,6 +44,12 @@ class AbortRequestMessage(EngineQueueMessage, kw_only=True):
request_ids: list[str]
class InteractionMessage(EngineQueueMessage, kw_only=True):
type: Literal["interaction"] = "interaction"
request_id: str
interaction: OmniInteractionPrompt
class CollectiveRPCRequestMessage(EngineQueueMessage, kw_only=True):
type: Literal["collective_rpc"] = "collective_rpc"
rpc_id: str
@@ -78,6 +84,7 @@ class ErrorMessage(EngineQueueMessage, kw_only=True):
fatal: bool = False
request_id: str | None = None
stage_id: int | None = None
event_id: str | None = None # for interactions on diffusion generation requests
class OutputMessage(EngineQueueMessage, kw_only=True):
+41
View File
@@ -40,6 +40,7 @@ from vllm_omni.engine.messages import (
CollectiveRPCResultMessage,
EngineQueueMessage,
ErrorMessage,
InteractionMessage,
OutputMessage,
RegisterRemoteReplicaMessage,
ShutdownRequestMessage,
@@ -577,6 +578,8 @@ class Orchestrator:
self.duplex_control_plane.dispatch(msg)
elif msg_type == "abort":
await self._handle_abort(msg)
elif msg_type == "interaction":
await self._handle_interaction(msg)
elif msg_type == "collective_rpc":
await self._handle_collective_rpc(msg)
elif isinstance(msg, RegisterRemoteReplicaMessage):
@@ -768,6 +771,44 @@ class Orchestrator:
)
logger.info("[Orchestrator] Aborted request(s) %s", request_ids)
async def _handle_interaction(self, msg: InteractionMessage) -> None:
"""Handle a midway interaction for an active streaming diffusion request."""
stage_id = 0
request_id = msg.request_id
event_id = msg.interaction.get("event_id")
req_state = self.request_states.get(request_id)
if req_state is None:
logger.info("[Orchestrator] Dropping interaction for inactive req %s", request_id)
await self.output_async_queue.put(
ErrorMessage(
error=f"No active request for interaction: {request_id}",
fatal=False,
request_id=request_id,
event_id=event_id,
stage_id=stage_id,
)
)
return
try:
await self.stage_pools[stage_id].submit_interaction(request_id, msg.interaction)
except Exception as exc:
logger.info(
"[Orchestrator] Failed interaction for req %s: %s",
request_id,
exc,
exc_info=True,
)
await self.output_async_queue.put(
ErrorMessage(
error=f"Failed interaction for request {request_id}: {exc}",
fatal=False,
request_id=request_id,
event_id=event_id,
stage_id=stage_id,
)
)
async def _abort_request_ids(self, request_ids: list[str]) -> None:
"""Forward abort requests to all stage pools."""
if not request_ids:
+13 -1
View File
@@ -8,7 +8,12 @@ from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from vllm.v1.engine import EngineCoreOutput, EngineCoreOutputs, EngineCoreRequest
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType, OmniTokensPrompt
from vllm_omni.inputs.data import (
OmniDiffusionSamplingParams,
OmniInteractionPrompt,
OmniPromptType,
OmniTokensPrompt,
)
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.inputs.data import OmniSamplingParams
@@ -101,3 +106,10 @@ class StagePoolDiffusionClient(StagePoolClient, Protocol):
) -> None: ...
def get_diffusion_output_nowait(self) -> OmniRequestOutput | None: ...
async def submit_interaction_async(
self,
request_id: str,
interaction: OmniInteractionPrompt,
timeout: float | None = None,
) -> Any: ...
+19 -1
View File
@@ -24,7 +24,7 @@ from vllm_omni.engine.stage_client import (
StagePoolDiffusionClient,
StagePoolLLMClient,
)
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniInteractionPrompt
from vllm_omni.metrics import (
count_audio_frames,
count_image_pixels,
@@ -1048,6 +1048,24 @@ class StagePool:
raise
return replica_id
async def submit_interaction(
self,
request_id: str,
interaction: OmniInteractionPrompt,
) -> int:
"""Submit a midway interaction to an active diffusion (typically video generation) request."""
replica_id = self.get_bound_replica_id(request_id)
if replica_id is None or self.clients[replica_id] is None:
replica_id = await self._pick_or_select(request_id)
client = self._diffusion_client(replica_id)
result = await client.submit_interaction_async(request_id, interaction)
# Interaction may raise error if underlying check against ODConfig fails.
if isinstance(result, dict) and result.get("error"):
reason = result.get("reason") or "Unknown interaction RPC error"
raise ValueError(str(reason))
return replica_id
async def _pick_or_select(
self,
request_id: str,
+41 -1
View File
@@ -50,7 +50,7 @@ if TYPE_CHECKING:
DuplexSessionLifecycleMessage,
)
from vllm_omni.experimental.fullduplex.request_client import DuplexRequestClient
from vllm_omni.inputs.data import OmniPromptType
from vllm_omni.inputs.data import OmniInteractionPrompt, OmniPromptType
logger = init_logger(__name__)
_FINAL_OUTPUT_IDLE_SLEEP_S = 0.001
@@ -1006,6 +1006,46 @@ class AsyncOmni(EngineClient, OmniBase):
internal_ids = [s.request_id for s in self.request_states.values() if s.external_request_id in request_ids]
await self._abort(internal_ids)
async def submit_interaction_async(
self,
request_id: str,
*,
interaction: OmniInteractionPrompt,
) -> None:
"""Apply a midway interaction to an active streaming diffusion request.
``request_id`` is the external id created by the server-side session,
matching the value passed to :meth:`generate`.
"""
event = interaction.get("event")
prompt = event.get("prompt") if isinstance(event, dict) else None
if isinstance(event, dict) and "prompt" in event and (not isinstance(prompt, str) or not prompt):
raise ValueError("prompt must be non-empty")
transition_chunks = interaction.get("transition_chunks")
if transition_chunks is not None and transition_chunks < 0:
raise ValueError("transition_chunks must be >= 0")
if self.num_stages != 1:
raise ValueError("interaction requires single-stage diffusion")
stage_meta = self.engine.get_stage_metadata(0)
if stage_meta.stage_type != "diffusion":
raise ValueError("interaction requires a diffusion stage")
internal_ids = [s.request_id for s in self.request_states.values() if s.external_request_id == request_id]
if not internal_ids:
raise ValueError(f"No active request for interaction: {request_id!r}")
if len(internal_ids) > 1:
raise ValueError(
f"interaction requires exactly one active request for {request_id!r}, found {len(internal_ids)}"
)
await self.engine.submit_interaction_async(
internal_ids[0],
interaction=interaction,
)
if self.log_stats:
logger.info("[AsyncOmni] Queued interaction for request %s", request_id)
async def _abort_internal_requests(self, request_id: str | Iterable[str]):
"""Abort request(s) via the Orchestrator given internal request IDs,
which take the format <external_request_id>-<UUID>.
@@ -5,13 +5,22 @@
Protocol:
Client -> Server:
{"type": "session.start", "model": "...", "prompt": "...", "format": "m4s", ...}
{"type": "session.prompt_update", "prompt": "..."} # currently unsupported
{
"type": "session.interaction",
"interaction": {
"event_id": "...",
"event": {"prompt": "..."},
"transition_chunks": (optional, integer),
},
}
{"type": "session.stop"}
{"type": "session.ping"} # optional; refreshes stall clock
Server -> Client:
{"type": "video.start", "request_id": "...", "config": {...}, "format": "m4s"}
{"type": "video.chunk_metadata", ...}
<binary frame: fragmented MP4 bytes>
{"type": "session.interaction.queued", "request_id": "...", "event_id": "..."}
{"type": "session.done", ...}
{"type": "session.pong"} # reply to session.ping
{"type": "error", "message": "..."}
@@ -21,7 +30,6 @@ from __future__ import annotations
import asyncio
import copy
import inspect
import json
import time
import uuid
@@ -29,19 +37,34 @@ from collections.abc import AsyncGenerator
from http import HTTPStatus
from typing import Any, cast
import numpy as np
import torch
from fastapi import HTTPException, WebSocket, WebSocketDisconnect
from PIL import Image
from pydantic import ValidationError
from vllm.engine.protocol import EngineClient
from vllm.logger import init_logger
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
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
from vllm_omni.entrypoints.openai.video_api_utils import (
StreamingVideoFormat,
create_streaming_video_encoder,
decode_input_reference,
)
from vllm_omni.inputs.data import (
OmniDiffusionSamplingParams,
OmniInteractionEvent,
OmniInteractionPrompt,
OmniTextPrompt,
)
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.outputs.output_metadata import DiffusionPayloadValue
logger = init_logger(__name__)
@@ -70,7 +93,7 @@ class OmniStreamingVideoOutputHandler:
def __init__(
self,
engine_client: EngineClient,
engine_client: AsyncOmni,
model_name: str | None = None,
stage_configs: list[Any] | None = None,
stall_timeout: float = _DEFAULT_STALL_TIMEOUT,
@@ -91,6 +114,7 @@ class OmniStreamingVideoOutputHandler:
control_task: asyncio.Task[None] | None = None
stopped = False
chunks_sent = 0
interaction_payloads_by_id: dict[str, OmniInteractionPrompt] = {}
try:
start = await self._receive_start(websocket)
@@ -112,13 +136,22 @@ class OmniStreamingVideoOutputHandler:
progress.touch()
control_task = asyncio.create_task(
self._control_loop(websocket, request_id, stop_event, send_lock, progress)
self._control_loop(
websocket,
request_id,
stop_event,
send_lock,
progress,
interaction_payloads_by_id,
)
)
async for chunk in self._stream_video_bytes(request, request_id, output_format, progress):
async for chunk, metadata in self._stream_video_bytes(request, request_id, output_format, progress):
if stop_event.is_set():
stopped = True
break
async with send_lock:
metadata["transport_chunk_index"] = chunks_sent
await websocket.send_json(metadata)
await websocket.send_bytes(chunk)
chunks_sent += 1
progress.touch()
@@ -234,6 +267,7 @@ class OmniStreamingVideoOutputHandler:
stop_event: asyncio.Event,
send_lock: asyncio.Lock,
progress: _SessionProgress,
interaction_payloads_by_id: dict[str, OmniInteractionPrompt],
) -> None:
while not stop_event.is_set():
if progress.stalled_for(self._stall_timeout):
@@ -282,34 +316,151 @@ class OmniStreamingVideoOutputHandler:
except Exception:
pass
continue
if msg_type == "session.prompt_update":
await self._send_error(
if msg_type == "session.interaction":
await self._handle_interaction(
websocket,
"session.prompt_update is not supported by this backend yet",
msg,
request_id=request_id,
send_lock=send_lock,
interaction_payloads_by_id=interaction_payloads_by_id,
)
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
await self._engine_client.abort(request_id)
except Exception:
logger.debug("Failed to abort streaming video request %s", request_id, exc_info=True)
async def _handle_interaction(
self,
websocket: WebSocket,
msg: dict[str, Any],
*,
request_id: str,
send_lock: asyncio.Lock,
interaction_payloads_by_id: dict[str, OmniInteractionPrompt],
) -> None:
interaction = msg.get("interaction")
if not isinstance(interaction, dict):
await self._send_error(websocket, "session.interaction requires an interaction object", send_lock=send_lock)
return
event_id = interaction.get("event_id")
if event_id is None:
event_id = uuid.uuid4().hex
elif not isinstance(event_id, str) or not event_id:
await self._send_error(
websocket,
"session.interaction event_id must be a non-empty string",
send_lock=send_lock,
)
return
event = interaction.get("event")
if not isinstance(event, dict):
await self._send_error(websocket, "session.interaction requires event object", send_lock=send_lock)
return
prompt = event.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
await self._send_error(
websocket,
"session.interaction requires a non-empty event.prompt",
send_lock=send_lock,
)
return
if "negative_prompt" in event and event["negative_prompt"] is not None:
await self._send_error(
websocket,
"session.interaction negative_prompt is not supported in this release",
send_lock=send_lock,
)
return
normalized: OmniInteractionPrompt = {
"event_id": event_id,
"event": cast(OmniInteractionEvent, event),
}
transition_chunks = interaction.get("transition_chunks")
if transition_chunks is not None:
try:
if (
isinstance(transition_chunks, bool)
or not isinstance(transition_chunks, (int, float))
or (isinstance(transition_chunks, float) and not transition_chunks.is_integer())
):
raise ValueError
normalized["transition_chunks"] = int(transition_chunks)
except (TypeError, ValueError):
await self._send_error(
websocket,
"session.interaction transition_chunks must be an integer",
send_lock=send_lock,
)
return
if normalized["transition_chunks"] < 0:
await self._send_error(
websocket,
"session.interaction transition_chunks must be >= 0",
send_lock=send_lock,
)
return
if event_id in interaction_payloads_by_id:
await self._send_error(
websocket,
"event_id_conflict",
code="event_id_conflict",
request_id=request_id,
event_id=event_id,
send_lock=send_lock,
)
return
interaction_payloads_by_id[event_id] = normalized
try:
await self._engine_client.submit_interaction_async(
request_id,
interaction=normalized,
)
except Exception:
logger.exception("Failed to apply interaction for request %s", request_id, exc_info=True)
interaction_payloads_by_id.pop(event_id, None)
await self._send_error(
websocket,
"Failed to apply interaction",
code="interaction_failed",
request_id=request_id,
event_id=event_id,
send_lock=send_lock,
)
return
try:
async with send_lock:
await websocket.send_json(
{
"type": "session.interaction.queued",
"request_id": request_id,
"event_id": event_id,
}
)
except Exception:
logger.debug("Failed to send interaction acknowledgement for %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]:
) -> AsyncGenerator[tuple[bytes, dict[str, Any]], None]:
"""Yield encoded video bytes from diffusion streaming outputs."""
prompt, gen_params, vp = self._build_prompt_and_sampling_params(request)
prompt, gen_params, vp = await 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"]
@@ -323,23 +474,41 @@ class OmniStreamingVideoOutputHandler:
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))
if result.error:
raise RuntimeError(str(result.error))
videos = self._extract_video_outputs(result)
for video in videos:
chunk = encoder.encode(video)
if chunk:
yield chunk
yield (
chunk,
self._build_chunk_metadata(
request_id=request_id,
kind="media",
byte_length=len(chunk),
num_frames=self._num_video_frames(video),
stream_metadata=self._extract_stream_metadata(result),
),
)
completed = True
final_chunk = encoder.close()
if final_chunk:
yield final_chunk
yield (
final_chunk,
self._build_chunk_metadata(
request_id=request_id,
kind="trailer",
byte_length=len(final_chunk),
num_frames=0,
stream_metadata={},
),
)
finally:
if not completed:
encoder.close()
def _build_prompt_and_sampling_params(
async def _build_prompt_and_sampling_params(
self,
request: VideoGenerationRequest,
) -> tuple[OmniTextPrompt, OmniDiffusionSamplingParams, VideoParams]:
@@ -350,6 +519,10 @@ class OmniStreamingVideoOutputHandler:
gen_params = self._resolve_default_sampling_params()
vp = request.resolve_video_params()
input_image = await self._decode_image_reference(request)
if input_image is not None:
prompt["multi_modal_data"] = {"image": input_image}
if vp.width is not None and vp.height is not None:
gen_params.width = vp.width
gen_params.height = vp.height
@@ -386,12 +559,28 @@ class OmniStreamingVideoOutputHandler:
self._apply_lora(request.lora, gen_params)
return prompt, gen_params, vp
@staticmethod
async def _decode_image_reference(request: VideoGenerationRequest) -> Image.Image | None:
try:
media_data = await decode_input_reference(
request.image_reference,
None,
None,
)
except InvalidInputReferenceError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST.value, detail=str(exc)) from exc
if media_data is not None and not isinstance(media_data, Image.Image):
raise HTTPException(
HTTPStatus.BAD_REQUEST.value,
detail="image_reference must resolve to an image.",
)
return media_data
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)
default_sampling_params_list = self._engine_client.default_sampling_params_list
for params in default_sampling_params_list:
if isinstance(params, OmniDiffusionSamplingParams):
return copy.deepcopy(params)
return OmniDiffusionSamplingParams()
@staticmethod
@@ -417,8 +606,8 @@ class OmniStreamingVideoOutputHandler:
gen_params: OmniDiffusionSamplingParams,
request_id: str,
progress: _SessionProgress,
) -> AsyncGenerator[Any, None]:
stage_configs = self._stage_configs or getattr(self._engine_client, "stage_configs", None)
) -> AsyncGenerator[OmniRequestOutput, None]:
stage_configs = self._stage_configs or self._engine_client.stage_configs
if not stage_configs:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
@@ -433,15 +622,14 @@ class OmniStreamingVideoOutputHandler:
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),
get_default_sampling_params_list(self._engine_client),
diffusion_params=gen_params,
replace_diffusion_params=True,
)
async for output in engine_client.generate(
async for output in self._engine_client.generate(
prompt=prompt,
request_id=request_id,
sampling_params_list=sampling_params_list,
@@ -450,44 +638,49 @@ class OmniStreamingVideoOutputHandler:
yield output
@staticmethod
def _normalize_video_outputs(videos: Any) -> list[Any]:
def _normalize_video_outputs(videos: DiffusionPayloadValue | None) -> list[DiffusionPayloadValue]:
if videos is None:
return []
if hasattr(videos, "ndim") and videos.ndim == 5:
if isinstance(videos, (torch.Tensor, np.ndarray)) 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] = []
if isinstance(first, (torch.Tensor, np.ndarray)) and first.ndim == 5:
flattened: list[DiffusionPayloadValue] = []
for item in videos:
if hasattr(item, "ndim") and item.ndim == 5:
if isinstance(item, (torch.Tensor, np.ndarray)) 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:
if isinstance(first, (torch.Tensor, np.ndarray)) and first.ndim == 3:
return [videos]
if isinstance(first, Image.Image):
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"):
def _extract_video_outputs(self, result: OmniRequestOutput) -> list[DiffusionPayloadValue]:
videos: DiffusionPayloadValue | None = None
if result.images:
# Video frames may be list[list[Image.Image]] despite images: list[Image.Image].
videos = result.images # type: ignore[assignment]
elif result.request_output is not None:
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:
elif isinstance(request_output, OmniRequestOutput):
if request_output.images:
# Same nested-frames mismatch as result.images above.
videos = request_output.images # type: ignore[assignment]
else:
request_multimodal_output = request_output.multimodal_output
videos = request_multimodal_output.get("video")
if videos is None:
videos = result.multimodal_output.get("video")
normalized = self._normalize_video_outputs(videos)
@@ -498,18 +691,64 @@ class OmniStreamingVideoOutputHandler:
)
return normalized
@staticmethod
def _extract_stream_metadata(result: OmniRequestOutput) -> dict[str, Any]:
multimodal_output = result.multimodal_output
if not isinstance(multimodal_output, dict):
return {}
metadata = multimodal_output.get("metadata")
if not isinstance(metadata, dict):
return {}
stream = metadata.get("stream")
return dict(stream) if isinstance(stream, dict) else {}
@staticmethod
def _num_video_frames(video: DiffusionPayloadValue) -> int:
if isinstance(video, (torch.Tensor, np.ndarray)) and len(video.shape) > 0:
return int(video.shape[0])
if isinstance(video, list):
return len(video)
return 0
@staticmethod
def _build_chunk_metadata(
*,
request_id: str,
kind: str,
byte_length: int,
num_frames: int,
stream_metadata: dict[str, Any],
) -> dict[str, Any]:
return {
"type": "video.chunk_metadata",
"request_id": request_id,
"kind": kind,
"num_frames": num_frames,
"byte_length": byte_length,
"generation_chunk_index": (stream_metadata.get("generation_chunk_index") if kind == "media" else None),
"started_event_ids": stream_metadata.get("started_event_ids", []),
"active_event_ids": stream_metadata.get("active_event_ids", []),
"completed_event_ids": stream_metadata.get("completed_event_ids", []),
}
@staticmethod
async def _send_error(
websocket: WebSocket,
message: str,
code: int | None = None,
code: int | str | None = None,
*,
request_id: str | None = None,
event_id: str | 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 request_id is not None:
payload["request_id"] = request_id
if event_id is not None:
payload["event_id"] = event_id
if send_lock is None:
await websocket.send_json(payload)
else:
+14
View File
@@ -43,6 +43,20 @@ class OmniTextPrompt(TextPrompt):
model_intermediate_buffer: NotRequired[dict[str, Any]]
class OmniInteractionEvent(OmniTextPrompt, total=False):
"""Prompt-event payload for mid-stream interactions."""
pass
class OmniInteractionPrompt(TypedDict, total=False):
"""Mid-stream interaction payload."""
event_id: NotRequired[str]
event: NotRequired[OmniInteractionEvent]
transition_chunks: NotRequired[int]
class OmniTokensPrompt(TokensPrompt):
"""Tokens prompt with optional embeddings and stage payloads.