[Core][Diffusion] Camera interaction for diffusion streaming generation (LingBot World 2 as example) (#7198)

Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com>
This commit is contained in:
Zeyu Huang | 黃澤宇 authored and GitHub committed 2026-09-20 16:19:41 +00:00
1 parent 139a47a578
commit e36babd48f
26 files changed
+2071 -200

No files matched your search

+32 -3
View File
@@ -41,7 +41,7 @@ bytes.
| Server to client | `video.start` | Confirms the request ID, format, and accepted configuration |
| Server to client | `video.chunk_metadata` | Describes the next binary media frame |
| Server to client | Binary frame | Carries one fragmented MP4 chunk |
| Client to server | `session.interaction` | Queues an optional prompt update during generation |
| Client to server | `session.interaction` | Queues an optional interaction (e.g., prompt. camera action) during generation |
| Server to client | `session.interaction.queued` | Confirms that an interaction was queued |
| Client to server | `session.ping` | Refreshes the stall clock |
| Server to client | `session.pong` | Acknowledges a ping |
@@ -83,10 +83,39 @@ Models that implement interaction updates can change the active prompt:
}
```
Models that register the camera modality (for example LingBot-World) also
accept structural SE3 payloads on `event.multi_modal_data.camera`. Coordinates
follow Unity semantics (`+X` right, `+Y` up, `+Z` forward; same as LingBot's
native camera frame). WASD key tokens are not admitted by the engine:
```json
{
"type": "session.interaction",
"interaction": {
"event_id": "forward-1",
"event": {
"multi_modal_data": {
"camera": {
"mode": "velocity",
"data": {
"translation": [0.0, 0.0, 0.05],
"rotation": [0.0, 0.0, 0.0, 1.0]
}
}
}
}
}
}
```
`mode` is `target` (lerp to an absolute pose over `transition_chunks`) or
`velocity` (hold a per-frame SE3 delta until replaced). Prompt and camera may
be combined in one interaction event.
This feature is pipeline-dependent. An enabled endpoint does not imply that
the loaded model supports midway prompt updates.
the loaded model supports midway prompt or camera updates.
The default start timeout is 10 seconds and the server reports a stall after
about 60 seconds without generation progress or a `session.ping`. See the
[streaming video generation example](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/streaming_video_generation)
for prompt-update scheduling and browser playback.
for prompt/camera-update scheduling and browser playback.
@@ -21,7 +21,7 @@ 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. Image-conditioned models take a first frame as `image_reference`: `{"image_url":"https://..."}` or a `data:` URL. |
| 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. |
| Client to server | `session.interaction` | JSON text: `{"type":"session.interaction","interaction":{"event_id":"xxx","event":{"prompt":"..."},"transition_chunks":3}}` | Updates the active prompt and/or camera midway through generation. Camera at `event.multi_modal_data.camera` is structural SE3 (`mode` + `translation`/`rotation`). `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. |
@@ -59,10 +59,10 @@ python streaming_video_client.py \
--output helios_stream.mp4
```
The client sends one `session.start` message, prints each received binary video chunk with its byte size and elapsed time, and saves the received bytes to `--output` after `session.done`.
The example 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`. None are sent unless you ask for them, and only pipelines that implement midway prompt updates accept them (LingBot-World, for one, does not, and rejects the whole session). Each entry uses `"at"` as seconds on the client clock after the server sends `video.start`:
Schedule midway prompt updates with `--prompt-updates`. None are sent unless you ask for them, and only pipelines that implement midway prompt updates accept them. Each entry uses `"at"` as seconds on the client clock after the server sends `video.start`:
```bash
python streaming_video_client.py \
@@ -73,6 +73,21 @@ python streaming_video_client.py \
]'
```
Schedule midway camera updates with `--camera-updates` when the served model
registers the camera modality (e.g., LingBot World 2).
It accepts `["camera"]["data"]["translation"/"rotation"]` in the endpoint's required SE3 format.
It also accepts `["actions"]` at the update level and converts keystrokes to the SE3 format.
```bash
python streaming_video_client.py \
--model robbyant/lingbot-world-v2-14b-causal-fast-diffusers \
--image-reference /path/to/first_frame.png \
--camera-updates '[
{"at": 1.0, "actions": ["w"]},
{"at": 3.0, "camera": {"mode": "target", "data": {"translation": [0.0, 0.0, 1.0], "rotation": [0.0, 0.0, 0.0, 1.0]}}, "transition_chunks": 2}
]'
```
The Helios demo continues the default prompt into an underwater storm:
```bash
@@ -144,6 +159,7 @@ python streaming_video_client.py \
--output lingbot_world_v2_stream.mp4
```
`camera_action_script` carries one three-latent-frame WASD action list per generated
chunk. Mid-session `session.interaction` only updates the prompt, so a LingBot rollout
follows the camera script it started with.
`camera_action_script` is an optional request-scoped WASD script (one
three-latent-frame action list per generated chunk). Prefer mid-session
`session.interaction` camera control via `--camera-updates` when you need
live motion; the engine wire format is structural SE3 only.
@@ -0,0 +1,70 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Client-side helpers that map WASD/IJKL keys to structural SE3 camera payloads.
Convenience for demos.
"""
from __future__ import annotations
import math
from typing import Any
# Unity-frame (+X right, +Y up, +Z forward) step sizes used by the optional
# WASD helper. Magnitudes mirror LingBot's script integrator; the engine never
# sees key tokens — only structural SE3 payloads.
_WASD_STEP = 0.05
_WASD_PITCH_DEG = 4.0
_WASD_YAW_DEG = 6.0
# Match LingBot's per-frame pitch clamp; without absolute pose this only limits
# a single helper call, not cumulative held velocity.
_WASD_PITCH_LIMIT_DEG = 85.0
def _quat_from_axis_angle(axis: tuple[float, float, float], degrees: float) -> list[float]:
"""Return a unit quaternion ``[x, y, z, w]`` for a right-handed axis-angle."""
radians = math.radians(degrees)
half = 0.5 * radians
sine = math.sin(half)
return [axis[0] * sine, axis[1] * sine, axis[2] * sine, math.cos(half)]
def wasd_to_camera_payload(actions: list[str], *, mode: str = "velocity") -> dict[str, Any]:
"""Map WASD/IJKL key tokens to a structural camera interaction payload.
This is a client convenience only. Servers reject ``data.actions``.
The delta is chosen so that at the identity pose, ``T_base @ T_delta``
matches LingBot's script integrator rotation
``R_y(yaw) @ R_x(pitch)``. Without the current absolute pose this remains
an approximation once the camera has already yawed/pitched.
"""
keys = {str(action).lower() for action in actions}
dx = _WASD_STEP * (("d" in keys) - ("a" in keys))
dy = 0.0
dz = _WASD_STEP * (("w" in keys) - ("s" in keys))
pitch = _WASD_PITCH_DEG * (("i" in keys) - ("k" in keys))
yaw = _WASD_YAW_DEG * (("l" in keys) - ("j" in keys))
pitch = max(-_WASD_PITCH_LIMIT_DEG, min(_WASD_PITCH_LIMIT_DEG, pitch))
# R_delta = R_y(yaw) @ R_x(pitch): apply pitch first, then yaw.
qx, qy, qz, qw = 0.0, 0.0, 0.0, 1.0
if abs(pitch) > 0.0:
px, py, pz, pw = _quat_from_axis_angle((1.0, 0.0, 0.0), pitch)
qx, qy, qz, qw = px, py, pz, pw
if abs(yaw) > 0.0:
yx, yy, yz, yw = _quat_from_axis_angle((0.0, 1.0, 0.0), yaw)
# q = yaw ⊗ pitch
qx, qy, qz, qw = (
yw * qx + yx * qw + yy * qz - yz * qy,
yw * qy - yx * qz + yy * qw + yz * qx,
yw * qz + yx * qy - yy * qx + yz * qw,
yw * qw - yx * qx - yy * qy - yz * qz,
)
return {
"mode": mode,
"data": {
"translation": [dx, dy, dz],
"rotation": [qx, qy, qz, qw],
},
}
@@ -26,6 +26,7 @@ from pathlib import Path
from typing import Any
import av
from camera_utils import wasd_to_camera_payload
try:
from websockets.asyncio.client import connect # pyright: ignore[reportMissingImports]
@@ -56,6 +57,15 @@ class ScheduledPromptUpdate:
transition_chunks: int
@dataclass(frozen=True)
class ScheduledCameraUpdate:
"""Client-side schedule entry for a midway camera ``session.interaction``."""
at: float
camera: dict[str, Any]
transition_chunks: int | None
def _image_reference(value: str) -> dict[str, Any]:
"""Accept an http(s) URL as-is, or inline a local image as a data URL."""
if value.startswith(("http://", "https://", "data:")):
@@ -119,6 +129,61 @@ def _parse_prompt_updates(value: str) -> list[ScheduledPromptUpdate]:
return sorted(updates, key=lambda update: update.at)
def _parse_camera_updates(value: str) -> list[ScheduledCameraUpdate]:
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
raise argparse.ArgumentTypeError(f"--camera-updates must be valid JSON: {exc}") from exc
if not isinstance(parsed, list):
raise argparse.ArgumentTypeError("--camera-updates must be a JSON array")
updates: list[ScheduledCameraUpdate] = []
for index, item in enumerate(parsed):
if not isinstance(item, dict):
raise argparse.ArgumentTypeError(f"--camera-updates[{index}] must be a JSON object")
try:
at = float(item["at"])
except (KeyError, TypeError, ValueError) as exc:
raise argparse.ArgumentTypeError(f"--camera-updates[{index}] must have a valid 'at' field") from exc
if at < 0:
raise argparse.ArgumentTypeError(f"--camera-updates[{index}].at must be >= 0")
if "camera" in item:
camera = item["camera"]
if not isinstance(camera, dict):
raise argparse.ArgumentTypeError(f"--camera-updates[{index}].camera must be an object")
elif "actions" in item:
actions = item["actions"]
if not isinstance(actions, list):
raise argparse.ArgumentTypeError(f"--camera-updates[{index}].actions must be a list of WASD/IJKL keys")
mode = item.get("mode", "velocity")
if mode not in ("target", "velocity"):
raise argparse.ArgumentTypeError(f"--camera-updates[{index}].mode must be target or velocity")
camera = wasd_to_camera_payload(actions, mode=mode)
else:
raise argparse.ArgumentTypeError(
f"--camera-updates[{index}] must provide 'camera' (structural SE3) "
"or 'actions' (client-side WASD helper)"
)
transition_chunks: int | None
if "transition_chunks" not in item:
transition_chunks = None if camera.get("mode") == "velocity" else DEFAULT_TRANSITION_CHUNKS
else:
try:
transition_chunks = int(item["transition_chunks"])
except (TypeError, ValueError) as exc:
raise argparse.ArgumentTypeError(
f"--camera-updates[{index}].transition_chunks must be an integer"
) from exc
if transition_chunks < 0:
raise argparse.ArgumentTypeError(f"--camera-updates[{index}].transition_chunks must be >= 0")
updates.append(ScheduledCameraUpdate(at=at, camera=camera, 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
@@ -173,7 +238,7 @@ async def _run_prompt_update_scheduler(
payload = {
"type": "session.interaction",
"interaction": {
"event_id": f"scheduled-{update.at:.3f}",
"event_id": f"scheduled-prompt-{update.at:.3f}",
"event": {"prompt": update.prompt},
"transition_chunks": update.transition_chunks,
},
@@ -183,6 +248,30 @@ async def _run_prompt_update_scheduler(
print(f"Sent session.interaction at t={update.at:.2f}s: {json.dumps(payload, ensure_ascii=False)}")
async def _run_camera_update_scheduler(
websocket: Any,
updates: list[ScheduledCameraUpdate],
*,
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)
interaction: dict[str, Any] = {
"event_id": f"scheduled-camera-{update.at:.3f}",
"event": {"multi_modal_data": {"camera": update.camera}},
}
if update.transition_chunks is not None:
interaction["transition_chunks"] = update.transition_chunks
payload = {"type": "session.interaction", "interaction": interaction}
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:
@@ -241,6 +330,7 @@ async def stream_video(args: argparse.Namespace) -> None:
ws = None
send_lock = asyncio.Lock()
prompt_update_task: asyncio.Task[None] | None = None
camera_update_task: asyncio.Task[None] | None = None
pending_chunk_metadata: dict[str, Any] | None = None
try:
@@ -295,8 +385,8 @@ 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')}")
video_started_at = time.perf_counter()
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,
@@ -305,6 +395,15 @@ async def stream_video(args: argparse.Namespace) -> None:
send_lock=send_lock,
)
)
if args.camera_updates and camera_update_task is None:
camera_update_task = asyncio.create_task(
_run_camera_update_scheduler(
websocket,
args.camera_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
@@ -315,6 +414,8 @@ async def stream_video(args: argparse.Namespace) -> None:
done = True
if prompt_update_task is not None:
prompt_update_task.cancel()
if camera_update_task is not None:
camera_update_task.cancel()
break
elif msg_type == "error":
print(f"ERROR: {msg.get('message', msg)}")
@@ -335,6 +436,10 @@ async def stream_video(args: argparse.Namespace) -> None:
prompt_update_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await prompt_update_task
if camera_update_task is not None:
camera_update_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await camera_update_task
print(
"Saving video... (May take a while. Remuxing concatenated chunks into one progressive MP4 file with total duration metadata in file header)"
)
@@ -405,9 +510,9 @@ def parse_args() -> argparse.Namespace:
default=None,
help="JSON object merged into extra_params; overrides preset keys on conflict.",
)
# Midway prompt updates are pipeline-specific: a model that does not implement
# them (LingBot-World, for one) rejects the whole session on the first one, so
# nothing is scheduled unless the caller asks for it.
# Midway interactions are pipeline-specific: unsupported modalities reject
# the update (and may leave the session running). Nothing is scheduled unless
# the caller asks for it.
parser.add_argument(
"--prompt-updates",
type=_parse_prompt_updates,
@@ -419,6 +524,19 @@ def parse_args() -> argparse.Namespace:
f"'transition_chunks' defaults to {DEFAULT_TRANSITION_CHUNKS}."
),
)
parser.add_argument(
"--camera-updates",
type=_parse_camera_updates,
default=[],
help=(
"JSON array of scheduled camera updates for models that register the "
"camera modality (e.g. LingBot-World). Each object needs 'at' and either "
"'camera' (structural SE3: mode + translation/rotation) or "
"'actions' (client-side WASD/IJKL helper converted before send). "
"Optional 'transition_chunks' defaults to None for velocity and "
f"{DEFAULT_TRANSITION_CHUNKS} for target."
),
)
return parser.parse_args()
+39 -23
View File
@@ -21,13 +21,12 @@ below.
- **Realtime stepwise** — see [Streaming video serving](#streaming-video-serving).
The suggested path: one `WS /v1/realtime/video` session produces the whole
rollout, one video chunk per AR block. It cannot take realtime camera
interaction yet; that is being added, and this path is the one that will
remain.
rollout, one video chunk per AR block. Mid-session camera control uses
structural SE3 `session.interaction` payloads; optional
`camera_action_script` remains for request-scoped WASD scripts.
- **Realtime tick** *(deprecated)* — see
[Realtime in-process generation](#realtime-in-process-generation-deprecated).
The only path that takes realtime camera interaction today, so it stays until
the stepwise path can, but it takes no new work and will be removed.
Older one-block-per-`generate()` control plane with JSONL WASD frames; will be removed in the future.
- **Offline** — see [Offline generation](#offline-generation). Replays a fixed
pose/intrinsics trajectory in one request and writes an MP4. Use it when the
camera path is known up front and streaming is not needed.
@@ -56,10 +55,10 @@ sufficient device memory.
## Realtime in-process generation (deprecated)
Use [Streaming video serving](#streaming-video-serving) instead unless you need
mid-session camera interaction, which only this path has. It still works and
emits no runtime warning; removal is tracked as B4 of the LingBot World 2.0
roadmap ([#6672](https://github.com/vllm-project/vllm-omni/issues/6672)).
Prefer [Streaming video serving](#streaming-video-serving) for mid-session
camera control. This tick example still works and emits no runtime warning;
removal is tracked as B4 of the LingBot World 2.0 roadmap
([#6672](https://github.com/vllm-project/vllm-omni/issues/6672)).
The realtime example keeps AR-Diffusion KV and model-owned state across
requests. Each JSONL line describes the prompt and/or three latent-frame camera
@@ -96,8 +95,8 @@ The stepwise path keeps AR-Diffusion paged KV but issues **one** request for
the whole rollout: `prepare_encode` runs once, then every AR block is four DMD
steps followed by one streamed chunk. Identity metadata uses
`session_id = request_id` with contiguous `chunk_index` values from zero.
Camera motion for this landing is request-scoped: one three-frame action list
per chunk, fixed when the rollout starts.
Camera motion can be request-scoped (`camera_action_script` / `action_path`) or
updated mid-session via `session.interaction`.
Serve it with the AR-Diffusion deploy config, which selects the AR-Diffusion
engine and enables streamed step execution:
@@ -115,11 +114,11 @@ keep their request-mode topology when no deploy config is given, and the
stepwise serving topology is only ever an explicit choice.
Clients then use the generic WebSocket protocol documented in
[`docs/serving/video_stream_api.md`](../../docs/serving/video_stream_api.md):
[`docs/serving/streaming_video_output_api.md`](../../docs/serving/streaming_video_output_api.md):
`session.start` begins one rollout and each AR block arrives as a binary video
chunk. The model is image-conditioned, so the first frame is required and rides
on `image_reference` as an `http(s)` or `data:` URL; per-chunk camera actions
ride on `extra_params`:
chunk. The model is image-conditioned, so the first frame is required
at `image_reference` as an `http(s)` or `data:` URL; an optional request-scoped
pre-scripted list of WASD actions can be provided in `extra_params`:
```json
{"type": "session.start", "model": "robbyant/lingbot-world-v2-14b-causal-fast-diffusers",
@@ -134,7 +133,26 @@ ride on `extra_params`:
chunk, and a request generates `((num_frames - 1) // 4 + 1) // 3` chunks:
three for `num_frames: 33`, seven for `num_frames: 81`.
The bundled client speaks this protocol:
Mid-session camera control uses structural SE3 on `session.interaction`
(Unity frame: `+X` right, `+Y` up, `+Z` forward). WASD key tokens belong in
clients and are required to be converted before submitting to the service:
```json
{"type": "session.interaction",
"interaction": {
"event_id": "forward-1",
"event": {
"multi_modal_data": {
"camera": {
"mode": "velocity",
"data": {"translation": [0.0, 0.0, 0.05], "rotation": [0.0, 0.0, 0.0, 1.0]}
}
}
}
}}
```
The bundled client helps translate the protocol, converting `--camera-updates` keystrokes to corresponding SE3 matrices:
```bash
python examples/online_serving/streaming_video_generation/streaming_video_client.py \
@@ -142,23 +160,21 @@ python examples/online_serving/streaming_video_generation/streaming_video_client
--prompt "The camera moves slowly forward through the scene." \
--image-reference /path/to/first_frame.png \
--width 832 --height 480 --num-frames 33 --fps 16 --seed 42 \
--extra-params '{"camera_action_script":[[["w"],["w"],["w"]],[["a"],[],[]],[[],[],[]]]}' \
--camera-updates '[{"at": 1.0, "actions": ["w"]}, {"at": 3.0, "actions": ["a"]}]' \
--output lingbot_world_v2_stream.mp4
```
A served request may instead point at a pose/intrinsics trajectory with
`extra_params.action_path`, which is resolved inside the trusted root set by
`model_config.lingbot_action_root` or `VLLM_OMNI_LINGBOT_ACTION_ROOT`; a server
started without that root configured accepts only `camera_action_script`.
started without that root configured accepts only `camera_action_script` or
mid-session camera interaction.
Requested `width`/`height` must match `ar_diffusion_width`/`ar_diffusion_height`
in the deploy config, because the AR cache geometry is fixed at load time.
Blocks are decoded independently, so seams between chunks are possible; a
session-owned streaming decoder is tracked separately.
Mid-session `session.interaction` for camera control is not wired yet, so a
served rollout follows the script it started with.
The deprecated tick example remains available for the older
one-block-per-`generate()` control plane.
@@ -199,8 +215,8 @@ tested commit.
- Only the 14B causal-fast checkpoint is supported.
- The tick control plane is internal; the public transport is the stepwise
`WS /v1/realtime/video` path, which cannot take mid-session camera
interaction yet.
`WS /v1/realtime/video` path with structural SE3 mid-session camera
interaction (WASD remains a client-side or `camera_action_script` convenience).
- Stepwise serving requires an explicit
`--deploy-config vllm_omni/deploy/lingbot_world_v2_stepwise.yaml`; there is
no default deploy config for this model.
@@ -12,6 +12,7 @@ from vllm_omni.diffusion.models.lingbot_world.actions import (
LINGBOT_CAMERA_ACTION_SCHEMA,
LINGBOT_CAMERA_TRAJECTORY_SCHEMA,
LingBotCameraControlReducer,
camera_trajectory_from_absolute_pose,
integrate_lingbot_camera_actions,
parse_lingbot_camera_action_frames,
parse_lingbot_camera_action_script,
@@ -212,6 +213,11 @@ def test_action_integrator_matches_lingbot_motion_and_calibration() -> None:
torch.tensor([500.0, 500.0, 208.0, 120.0]),
)
with pytest.raises(ValueError, match="resolution must be positive"):
camera_trajectory_from_absolute_pose(trajectory.poses[:1], width=0, height=480)
with pytest.raises(ValueError, match=r"\[frames, 4, 4\]"):
camera_trajectory_from_absolute_pose(torch.zeros(4, 4), width=832, height=480)
@pytest.mark.parametrize(
("data", "message"),
@@ -422,7 +422,17 @@ def test_build_plucker_embedding_uses_framewise_deltas_for_rotations_and_transla
torch.testing.assert_close(result[2, 3:, 0, 1], expected_direction)
def test_build_plucker_embedding_normalizes_framewise_translation_by_max_norm() -> None:
@pytest.mark.parametrize(
("translation_scale", "expected_origins"),
[
(None, [[0.0, 0.0, 0.0], [0.3, 0.4, 0.0], [0.6, 0.8, 0.0]]),
(0.05, [[0.0, 0.0, 0.0], [60.0, 80.0, 0.0], [120.0, 160.0, 0.0]]),
],
)
def test_build_plucker_embedding_normalizes_framewise_translation_by_max_norm(
translation_scale: float | None,
expected_origins: list[list[float]],
) -> None:
poses = torch.eye(4).repeat(3, 1, 1)
poses[:, :3, 3] = torch.tensor(
[
@@ -440,10 +450,37 @@ def test_build_plucker_embedding_normalizes_framewise_translation_by_max_norm()
target_width=1,
device=torch.device("cpu"),
dtype=torch.float32,
translation_scale=translation_scale,
)
expected_origins = torch.tensor([[0.0, 0.0, 0.0], [0.3, 0.4, 0.0], [0.6, 0.8, 0.0]])
torch.testing.assert_close(result[:, :3, 0, 0], expected_origins)
torch.testing.assert_close(result[:, :3, 0, 0], torch.tensor(expected_origins))
from vllm_omni.diffusion.models.lingbot_world.actions import LINGBOT_CONTROLLER_TRANSLATION_UNIT
if translation_scale != LINGBOT_CONTROLLER_TRANSLATION_UNIT:
return
# Fixed controller unit keeps relative speed; max-norm would collapse both to 1.
slow = torch.eye(4).repeat(2, 1, 1)
slow[1, 2, 3] = 0.005
fast = torch.eye(4).repeat(2, 1, 1)
fast[1, 2, 3] = 0.5
kwargs = dict(
height=1,
width=1,
target_height=1,
target_width=1,
device=torch.device("cpu"),
dtype=torch.float32,
translation_scale=LINGBOT_CONTROLLER_TRANSLATION_UNIT,
)
slow_origin = build_plucker_embedding(_trajectory(slow), **kwargs)[1, :3, 0, 0]
fast_origin = build_plucker_embedding(_trajectory(fast), **kwargs)[1, :3, 0, 0]
torch.testing.assert_close(slow_origin, torch.tensor([0.0, 0.0, 0.1]))
torch.testing.assert_close(fast_origin, torch.tensor([0.0, 0.0, 10.0]))
wasd = torch.eye(4).repeat(2, 1, 1)
wasd[1, 2, 3] = LINGBOT_CONTROLLER_TRANSLATION_UNIT
wasd_origin = build_plucker_embedding(_trajectory(wasd), **kwargs)[1, :3, 0, 0]
torch.testing.assert_close(wasd_origin, torch.tensor([0.0, 0.0, 1.0]))
def test_build_plucker_embedding_interpolates_raw_poses_before_framewise_conversion() -> None:
@@ -18,6 +18,7 @@ from torch import nn
import vllm_omni.diffusion.models.lingbot_world.dmd_block as lingbot_dmd_block
import vllm_omni.diffusion.models.lingbot_world.pipeline as lingbot_pipeline
from tests.diffusion.models.wan2_2.conftest import noop_progress_bar
from vllm_omni.diffusion.interaction.modality_handlers.camera import CameraSession
from vllm_omni.diffusion.models.interface import SupportsStepExecution, supports_step_execution
from vllm_omni.diffusion.models.lingbot_world.actions import (
integrate_lingbot_camera_actions,
@@ -411,8 +412,10 @@ def _stub_pipeline_dependencies(monkeypatch: pytest.MonkeyPatch) -> None:
intrinsics=value.intrinsics[:num_frames],
)
def build_plucker_embedding(value, *, height, width, target_height, target_width, device, dtype):
del target_height, target_width
def build_plucker_embedding(
value, *, height, width, target_height, target_width, device, dtype, translation_scale=None
):
del target_height, target_width, translation_scale
frames = value.poses.shape[0]
data = torch.arange(frames * 6 * height * width, device=device, dtype=torch.float32)
return data.reshape(frames, 6, height, width).to(dtype=dtype)
@@ -1783,6 +1786,7 @@ def test_first_typed_yaw_action_uses_pre_action_identity_anchor(
target_width=16,
device=torch.device("cpu"),
dtype=torch.float32,
translation_scale=None, # None for camera_actions / action_script input (using max-norm)
)[1:]
assert not torch.equal(action_embedding, neutral_embedding)
@@ -2015,6 +2019,7 @@ def test_stepwise_failed_chunk_releases_pending_encoder_on_close(monkeypatch: py
transformer.raise_on_call = 1
with pipeline.bind_ar_diffusion_state(state.request_id, _FakeARState(state.request_id)):
pipeline.prepare_encode(state)
pipeline.prepare_next_chunk(state)
session = pipeline._ar_sessions[state.request_id]
assert session.encoder_cache is None and session.pending_encoder_cache is not None
with pytest.raises(RuntimeError, match="forced transformer failure"):
@@ -2037,6 +2042,7 @@ def test_stateful_condition_rejects_tiled_encoder(monkeypatch: pytest.MonkeyPatc
state = _stepwise_state()
with pipeline.bind_ar_diffusion_state(state.request_id, _FakeARState(state.request_id)):
pipeline.prepare_encode(state)
pipeline.prepare_next_chunk(state)
assert pipeline.vae.encoder.inputs == []
assert all(s.encoder_cache is None and s.pending_encoder_cache is None for s in pipeline._ar_sessions.values())
@@ -2168,11 +2174,14 @@ def _stepwise_state(
def _run_stepwise(pipeline, state):
outputs = []
pipeline.prepare_encode(state)
pipeline.prepare_next_chunk(state)
while not state.request_denoise_completed:
noise = pipeline.denoise_step(None, states=[state])
pipeline.step_scheduler(state, noise)
if state.chunk_denoise_completed:
outputs.append(pipeline.post_decode(state))
if not state.request_denoise_completed:
pipeline.prepare_next_chunk(state)
return outputs
@@ -2186,6 +2195,7 @@ def _stepwise_chunks(pipeline, state, ar_state):
"""
with pipeline.bind_ar_diffusion_state(state.request_id, ar_state):
pipeline.prepare_encode(state)
pipeline.prepare_next_chunk(state)
while not state.request_denoise_completed:
output = None
with pipeline.bind_ar_diffusion_state(state.request_id, ar_state):
@@ -2193,6 +2203,8 @@ def _stepwise_chunks(pipeline, state, ar_state):
pipeline.step_scheduler(state, noise)
if state.chunk_denoise_completed:
output = pipeline.post_decode(state)
if not state.request_denoise_completed:
pipeline.prepare_next_chunk(state)
if output is not None:
yield output
@@ -2463,6 +2475,64 @@ def test_stepwise_chunks_continue_one_sessions_temporal_decode(monkeypatch) -> N
assert pipeline.vae._feat_map == ["module-owned"]
def test_prepare_next_chunk_rejects_live_camera_with_scripted_paths() -> None:
"""Scripted/cached camera must not silently ignore mid-generation camera events."""
module = _load_pipeline_module()
pipeline = _pipeline(module, transformer=_RecordingTransformer())
pipeline._ar_height = 16
pipeline._ar_width = 16
# Scenario 1, input pre-scripted camera trajectory via action_script
state = _stepwise_state(num_frames=21)
with pipeline.bind_ar_diffusion_state(state.request_id, _FakeARState(state.request_id)):
pipeline.prepare_encode(state)
assert state.extra.get("camera_action_script") is not None
state.interaction_sessions["camera"] = CameraSession(has_received_input=True)
with pytest.raises(ValueError, match="camera_action_script"):
pipeline.prepare_next_chunk(state)
# Scenario 2, input pre-scripted camera trajectory via embedding cache
poses = torch.eye(4).repeat(21, 1, 1)
trajectory = _CameraTrajectory(
poses=poses,
intrinsics=torch.tensor([[100.0, 100.0, 8.0, 8.0]]).repeat(21, 1),
)
state = _stepwise_state(num_frames=21)
state.sampling.extra_args["_lingbot_camera_trajectory"] = trajectory
state.sampling.extra_args.pop("_lingbot_camera_action_script")
with pipeline.bind_ar_diffusion_state(state.request_id, _FakeARState(state.request_id)): # pyright: ignore[reportArgumentType]
pipeline.prepare_encode(state)
assert state.extra.get("camera_embedding_cache") is not None
state.interaction_sessions["camera"] = CameraSession(has_received_input=True)
with pytest.raises(ValueError, match="camera_embedding_cache"):
pipeline.prepare_next_chunk(state)
def test_peek_chunk_media_matches_streaming_decoder_frame_counts(monkeypatch) -> None:
"""Camera timelines must track the streaming decoder's 9-then-12 media counts."""
module = _load_pipeline_module()
_capture_video_processor(monkeypatch)
pipeline = _streaming_pipeline(module)
state = _stepwise_state(num_frames=21)
state.sampling.output_type = "np"
state.sampling.fps = 16.0
fake = _FakeARState(state.request_id)
with pipeline.bind_ar_diffusion_state(state.request_id, fake):
pipeline.prepare_encode(state)
first = pipeline.peek_chunk_media(state)
assert (first.num_media_frames, first.fps, first.num_latent_frames) == (9, 16.0, 3)
pipeline.prepare_next_chunk(state)
while not state.chunk_denoise_completed:
noise = pipeline.denoise_step(None, states=[state])
pipeline.step_scheduler(state, noise)
pipeline.post_decode(state)
second = pipeline.peek_chunk_media(state)
assert (second.num_media_frames, second.fps, second.num_latent_frames) == (12, 16.0, 3)
def test_streaming_decode_state_is_owned_by_the_session(monkeypatch) -> None:
"""Decoder state is keyed by request id and released with the AR session."""
module = _load_pipeline_module()
@@ -2599,6 +2669,7 @@ def test_streaming_decode_receives_rescaled_latents(monkeypatch) -> None:
with pipeline.bind_ar_diffusion_state(state.request_id, _FakeARState(state.request_id)):
pipeline.prepare_encode(state)
pipeline.prepare_next_chunk(state)
while not state.chunk_denoise_completed:
pipeline.step_scheduler(state, pipeline.denoise_step(None, states=[state]))
model_space = state.latents.clone()
@@ -0,0 +1,434 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Tests for camera interaction handlers and coordinator registration."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import torch
from vllm_omni.diffusion.interaction.coordinator import InteractionCoordinator
from vllm_omni.diffusion.interaction.mixin import InteractionMixin
from vllm_omni.diffusion.interaction.modality_handlers.camera import CameraSession, SE3DeltaCameraHandler
from vllm_omni.diffusion.interaction.modality_handlers.prompt import PromptSession
from vllm_omni.diffusion.interaction.registry import STRUCTURED_HANDLER_REGISTRY
from vllm_omni.diffusion.interaction.types import ChunkMediaSpec, resolve_event_frame_offset
from vllm_omni.diffusion.worker.utils import StepRequestState
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
_FORWARD_VELOCITY = {
"mode": "velocity",
"data": {"translation": [0.0, 0.0, 0.05], "rotation": [0.0, 0.0, 0.0, 1.0]},
}
class _FakePromptPipeline(InteractionMixin):
"""Minimal pipeline stub with prompt-update support."""
def __init__(self) -> None:
self.device = torch.device("cpu")
self.transformer = SimpleNamespace(dtype=torch.float32)
self.encode_prompt = MagicMock(return_value=(torch.full((1, 4, 2), 2.0), None))
self._interaction_coordinator = None
def peek_chunk_media(self, state: StepRequestState) -> ChunkMediaSpec:
del state
return ChunkMediaSpec(num_media_frames=8, fps=16.0, num_latent_frames=8)
class _FakeLingBotPipeline(InteractionMixin):
"""Minimal pipeline stub for LingBot camera registration."""
def __init__(self) -> None:
self._interaction_coordinator = None
def peek_chunk_media(self, state: StepRequestState) -> ChunkMediaSpec:
del state
return ChunkMediaSpec(num_media_frames=3, fps=3.0, num_latent_frames=3)
def _make_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 _boundary_at(previous_boundary_at: float | None, num_frames: int, fps: float) -> float:
if previous_boundary_at is None:
return 0.0
if fps <= 0:
return previous_boundary_at
return previous_boundary_at + num_frames / fps
class TestCoordinatorResolution:
def test_helios_has_prompt_but_no_camera(self) -> None:
pipeline = _FakePromptPipeline()
od_config = SimpleNamespace(model_class_name="HeliosPipeline")
coordinator = InteractionCoordinator.build(pipeline, od_config)
assert coordinator.has_modality("prompt")
assert not coordinator.has_modality("camera")
assert "camera" not in STRUCTURED_HANDLER_REGISTRY["HeliosPipeline"]
def test_lingbot_registers_se3_camera_handler(self) -> None:
pipeline = _FakeLingBotPipeline()
od_config = SimpleNamespace(model_class_name="LingBotWorldCausalDMDPipeline")
coordinator = InteractionCoordinator.build(pipeline, od_config)
assert coordinator.has_modality("camera")
handler = coordinator.get_handler("camera")
assert isinstance(handler, SE3DeltaCameraHandler)
assert handler.lazy_initialize_session is False
def test_non_lazy_camera_apply_creates_session_without_enqueue(self) -> None:
"""Camera apply must run before any enqueue to materialize identity poses."""
pipeline = _FakeLingBotPipeline()
od_config = SimpleNamespace(model_class_name="LingBotWorldCausalDMDPipeline")
coordinator = InteractionCoordinator.build(pipeline, od_config)
state = _make_state()
assert "camera" not in state.interaction_sessions
meta = coordinator.maybe_prepare_initial_session(state, pipeline)
session = state.interaction_sessions["camera"]
assert isinstance(session, CameraSession)
assert session.last_absolute_poses is not None
assert session.last_absolute_poses.shape == (3, 4, 4)
assert meta.started_event_ids == []
assert meta.active_event_ids == []
def test_lazy_prompt_apply_skips_without_enqueue(self) -> None:
pipeline = _FakePromptPipeline()
od_config = SimpleNamespace(model_class_name="HeliosPipeline")
coordinator = InteractionCoordinator.build(pipeline, od_config)
state = _make_state()
meta = coordinator.apply_at_chunk_boundary(state, boundary_at=0.0, chunk_index=0)
assert "prompt" not in state.interaction_sessions
assert meta.started_event_ids == []
assert meta.active_event_ids == []
assert meta.completed_event_ids == []
initial = coordinator.maybe_prepare_initial_session(state, pipeline)
assert "prompt" not in state.interaction_sessions
assert initial.started_event_ids == []
def test_unsupported_modality_includes_model_context(self) -> None:
pipeline = _FakePromptPipeline()
od_config = SimpleNamespace(model_class_name="HeliosPipeline")
coordinator = InteractionCoordinator.build(pipeline, od_config)
with pytest.raises(ValueError, match="HeliosPipeline"):
coordinator.enqueue(
_make_state(),
modality="camera",
event_id="cam-1",
received_at=0.0,
payload=_FORWARD_VELOCITY,
transition_chunks=None,
)
def test_enqueue_parts_rejects_unsupported_modality_atomically(self) -> None:
"""Unsupported tracks in a composite event must not mutate any queues."""
pipeline = _FakePromptPipeline()
od_config = SimpleNamespace(model_class_name="HeliosPipeline")
coordinator = InteractionCoordinator.build(pipeline, od_config)
state = _make_state()
coordinator.enqueue(
state,
modality="prompt",
event_id="prior",
received_at=0.0,
payload={"prompt": "already-queued"},
transition_chunks=1,
)
prior_session = state.interaction_sessions["prompt"]
assert isinstance(prior_session, PromptSession)
prior = prior_session.pending_event
assert prior is not None
pipeline.encode_prompt.reset_mock()
with pytest.raises(ValueError, match="camera"):
coordinator.enqueue_parts(
state,
parts=[
("prompt", {"prompt": "should-not-replace"}),
("camera", _FORWARD_VELOCITY),
],
event_id="composite-bad",
received_at=1.0,
transition_chunks=2,
)
pipeline.encode_prompt.assert_not_called()
pending = prior_session.pending_event
assert pending is not None
assert pending.event_id == "prior"
assert pending.prompt == "already-queued"
assert "camera" not in state.interaction_sessions
# Invalid camera payload must also fail before any track is queued.
dual = InteractionCoordinator(
{
"prompt": coordinator.get_handler("prompt"),
"camera": SE3DeltaCameraHandler(),
},
model_class_name="HeliosPipeline",
)
pipeline.encode_prompt.reset_mock()
with pytest.raises(ValueError, match="actions"):
dual.enqueue_parts(
state,
parts=[
("prompt", {"prompt": "should-not-replace"}),
("camera", {"mode": "velocity", "data": {"actions": ["w"]}}),
],
event_id="composite-bad-payload",
received_at=2.0,
transition_chunks=2,
)
pipeline.encode_prompt.assert_not_called()
pending = prior_session.pending_event
assert pending is not None
assert pending.event_id == "prior"
assert pending.prompt == "already-queued"
assert "camera" not in state.interaction_sessions
class TestCameraHandlers:
def test_rejects_wasd_actions_payload(self) -> None:
handler = SE3DeltaCameraHandler()
state = _make_state()
with pytest.raises(ValueError, match="actions"):
handler.enqueue(
state,
event_id="cam-wasd",
received_at=0.0,
payload={"mode": "velocity", "data": {"actions": ["w"]}},
transition_chunks=None,
)
def test_target_transition_progress_and_se3_projection(self) -> None:
handler = SE3DeltaCameraHandler()
state = _make_state()
handler.enqueue(
state,
event_id="cam-1",
received_at=0.0,
payload={
"mode": "target",
"data": {"translation": [0.0, 0.0, 3.0], "rotation": [0.0, 0.0, 0.0, 1.0]},
},
transition_chunks=2,
)
meta = handler.apply_at_chunk_boundary(
state,
chunk_index=0,
num_media_frames=4,
fps=16.0,
boundary_at=0.25,
)
session = state.interaction_sessions["camera"]
assert isinstance(session, CameraSession)
assert session.last_absolute_poses is not None
assert meta is not None
assert meta.started_event_ids == ["cam-1"]
assert meta.active_event_ids == ["cam-1"]
assert meta.completed_event_ids == []
assert session.last_absolute_poses.shape == (4, 4, 4)
meta2 = handler.apply_at_chunk_boundary(
state,
chunk_index=1,
num_media_frames=4,
fps=16.0,
boundary_at=0.5,
)
assert meta2 is not None
assert meta2.started_event_ids == []
assert meta2.active_event_ids == []
assert meta2.completed_event_ids == ["cam-1"]
assert session.active_event is None
assert session.current_pose.translation[2] == pytest.approx(3.0)
assert session.last_absolute_poses is not None
assert session.last_absolute_poses.shape == (4, 4, 4)
def test_single_chunk_target_completes_once(self) -> None:
handler = SE3DeltaCameraHandler()
# Include a non-power-of-two frame count (7) so float reciprocal drift
# would previously miss the final snap / completion ack.
for num_frames in (8, 7):
state = _make_state()
handler.enqueue(
state,
event_id="cam-fast",
received_at=0.0,
payload={
"mode": "target",
"data": {"translation": [1.0, 0.0, 0.0], "rotation": [0.0, 0.0, 0.0, 1.0]},
},
transition_chunks=1,
)
meta = handler.apply_at_chunk_boundary(
state,
chunk_index=0,
num_media_frames=num_frames,
fps=16.0,
boundary_at=0.5,
)
assert meta is not None
assert meta.started_event_ids == ["cam-fast"]
assert meta.active_event_ids == []
assert meta.completed_event_ids == ["cam-fast"]
meta2 = handler.apply_at_chunk_boundary(
state,
chunk_index=1,
num_media_frames=num_frames,
fps=16.0,
boundary_at=1.0,
)
assert meta2 is not None
assert meta2.started_event_ids == []
assert meta2.active_event_ids == []
assert meta2.completed_event_ids == []
session = state.interaction_sessions["camera"]
assert isinstance(session, CameraSession)
assert session.current_pose.translation[0] == pytest.approx(1.0)
def test_velocity_composes_structural_se3_delta(self) -> None:
handler = SE3DeltaCameraHandler()
state = _make_state()
handler.enqueue(
state,
event_id="vel-1",
received_at=0.0,
payload=_FORWARD_VELOCITY,
transition_chunks=None,
)
meta0 = handler.apply_at_chunk_boundary(state, chunk_index=0, num_media_frames=3, fps=16.0, boundary_at=0.0)
assert meta0 is not None
assert meta0.started_event_ids == ["vel-1"]
assert meta0.active_event_ids == ["vel-1"]
session = state.interaction_sessions["camera"]
assert isinstance(session, CameraSession)
assert session.current_pose.translation[2] == pytest.approx(0.15)
assert session.last_absolute_poses is not None
assert session.last_absolute_poses.shape == (3, 4, 4)
torch.testing.assert_close(
session.last_absolute_poses[0, :3, 3],
torch.tensor([0.0, 0.0, 0.05], dtype=torch.float64),
atol=1e-6,
rtol=0,
)
torch.testing.assert_close(
session.last_absolute_poses[1, :3, 3],
torch.tensor([0.0, 0.0, 0.10], dtype=torch.float64),
atol=1e-6,
rtol=0,
)
meta1 = handler.apply_at_chunk_boundary(state, chunk_index=1, num_media_frames=2, fps=16.0, boundary_at=0.125)
assert meta1 is not None
assert meta1.active_event_ids == ["vel-1"]
assert session.current_pose.translation[2] == pytest.approx(0.25)
def test_velocity_completed_when_replaced_by_target(self) -> None:
handler = SE3DeltaCameraHandler()
state = _make_state()
handler.enqueue(
state,
event_id="vel-1",
received_at=0.0,
payload=_FORWARD_VELOCITY,
transition_chunks=None,
)
meta0 = handler.apply_at_chunk_boundary(
state,
chunk_index=0,
num_media_frames=2,
fps=16.0,
boundary_at=0.0,
)
assert meta0 is not None
assert meta0.active_event_ids == ["vel-1"]
handler.enqueue(
state,
event_id="tgt-1",
received_at=0.1,
payload={
"mode": "target",
"data": {"translation": [0.0, 0.0, 0.0], "rotation": [0.0, 0.0, 0.0, 1.0]},
},
transition_chunks=0,
)
meta1 = handler.apply_at_chunk_boundary(
state,
chunk_index=1,
num_media_frames=2,
fps=16.0,
boundary_at=0.125,
)
assert meta1 is not None
assert meta1.started_event_ids == ["tgt-1"]
assert "vel-1" in meta1.completed_event_ids
assert meta1.active_event_ids == []
def test_same_chunk_velocity_then_target_by_frame_offset(self) -> None:
handler = SE3DeltaCameraHandler()
state = _make_state()
fps = 10.0
boundary = 0.0
handler.enqueue(
state,
event_id="vel",
received_at=0.0,
payload=_FORWARD_VELOCITY,
transition_chunks=None,
)
handler.enqueue(
state,
event_id="tgt",
received_at=0.5,
payload={
"mode": "target",
"data": {"translation": [0.0, 0.0, 0.0], "rotation": [0.0, 0.0, 0.0, 1.0]},
},
transition_chunks=0,
)
state.interaction_sessions["camera"].last_boundary_at = boundary
meta = handler.apply_at_chunk_boundary(
state,
chunk_index=0,
num_media_frames=10,
fps=fps,
boundary_at=_boundary_at(boundary, 10, fps),
)
assert meta is not None
assert meta.started_event_ids == ["vel", "tgt"]
assert meta.completed_event_ids == ["vel", "tgt"]
assert meta.active_event_ids == []
session = state.interaction_sessions["camera"]
assert isinstance(session, CameraSession)
assert session.active_event is None
assert session.current_pose.translation[2] == pytest.approx(0.0)
class TestResolveEventFrameOffset:
def test_clamps_and_floors(self) -> None:
assert resolve_event_frame_offset(received_at=0.0, previous_boundary_at=None, num_frames=8, fps=16.0) == 0
assert resolve_event_frame_offset(received_at=0.1, previous_boundary_at=0.0, num_frames=8, fps=16.0) == 1
assert resolve_event_frame_offset(received_at=10.0, previous_boundary_at=0.0, num_frames=8, fps=16.0) == 7
assert resolve_event_frame_offset(received_at=-1.0, previous_boundary_at=0.0, num_frames=8, fps=16.0) == 0
@@ -78,7 +78,7 @@ def _make_diffusion_request_state(*, request_id: str = "req-1", fps: int | None
prompt="hello",
)
state.prompt_embeds = torch.zeros(1, 4, 2)
state.extra = {"window_num_frames": 8}
state.extra = {"window_num_frames": 8, "num_latent_frames_per_chunk": 3}
return state
@@ -109,7 +109,12 @@ def _make_diffusion_model_runner(
# instances; unit tests of submit_interaction stub this. Stepwise path tests
# rebind to the real method.
runner._supports_step_mode = lambda: True
runner._interaction_coordinator = None
# Mirror load_model: coordinator is always present once the runner is "loaded".
# Build now so handlers capture the pipeline's current encode_prompt (tests may
# rebind the mock before calling this helper).
runner._interaction_coordinator = InteractionCoordinator.build(pipeline, runner.od_config)
if hasattr(pipeline, "_interaction_coordinator"):
pipeline._interaction_coordinator = runner._interaction_coordinator
return runner
@@ -155,6 +160,7 @@ class TestPromptUpdateExecution:
supports_step_execution = True
runner = _make_diffusion_model_runner(pipeline=_UnsupportedPipeline())
runner.state_cache["req-1"] = _make_diffusion_request_state()
with pytest.raises(ValueError, match="not supported"):
runner.submit_interaction("req-1", _prompt_interaction())
@@ -168,25 +174,98 @@ class TestPromptUpdateExecution:
"interaction",
[
{},
{"multi_modal_data": {"camera": {"type": "pose"}}},
{"event": {"prompt": "updated", "multi_modal_data": {"camera": {"type": "pose"}}}},
{"event": {"prompt": "updated", "multi_modal_data": {}}},
{"event": {"prompt": "updated", "multi_modal_data": None}},
{"event": {"prompt": "updated", "multi_modal_data": "bad"}},
{"event_id": "bad", "event": {"multi_modal_data": {}}},
{"event_id": "bad", "event": {"multi_modal_data": None}},
{"event_id": "bad", "event": {"prompt": "updated", "multi_modal_data": "bad"}},
{
"event_id": "cam-only",
"event": {
"multi_modal_data": {
"camera": {
"mode": "velocity",
"data": {"translation": [0.0, 0.0, 0.05], "rotation": [0.0, 0.0, 0.0, 1.0]},
},
}
},
},
{
"event_id": "cam-and-prompt",
"event": {
"prompt": "updated",
"multi_modal_data": {
"camera": {
"mode": "velocity",
"data": {"translation": [0.0, 0.0, 0.05], "rotation": [0.0, 0.0, 0.0, 1.0]},
},
},
},
},
],
)
def test_runner_interaction_rejects_structural_payloads_until_implemented(
def test_runner_rejects_unsupported_interactions(
self,
pipeline: HeliosPipeline,
interaction: dict[str, Any],
) -> None:
"""Unsupported interaction dict shapes are preserved to and rejected by the runner."""
"""Helios has no camera handler; malformed / unsupported payloads are rejected."""
runner = _make_diffusion_model_runner(pipeline=pipeline)
runner.state_cache["req-1"] = _make_diffusion_request_state()
state = _make_diffusion_request_state()
runner.state_cache["req-1"] = state
with pytest.raises(NotImplementedError, match="Only text-only prompt update interactions"):
with pytest.raises((ValueError, KeyError, AttributeError, TypeError)):
runner.submit_interaction("req-1", cast(Any, interaction))
# Rejected composite/malformed events must leave interaction queues unchanged.
pipeline.encode_prompt.assert_not_called() # pyright: ignore[reportAttributeAccessIssue]
assert "prompt" not in state.interaction_sessions
assert "camera" not in state.interaction_sessions
def test_rejected_composite_leaves_existing_prompt_queue_unchanged(
self,
pipeline: HeliosPipeline,
) -> None:
"""Helios must not apply a composite prompt when camera modality is unsupported."""
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("already-queued", event_id="prior"))
session = state.interaction_sessions["prompt"]
assert isinstance(session, PromptSession)
prior = session.pending_event
assert prior is not None
assert prior.event_id == "prior"
pipeline.encode_prompt.reset_mock() # pyright: ignore[reportAttributeAccessIssue]
with pytest.raises(ValueError, match="camera"):
runner.submit_interaction(
"req-1",
cast(
Any,
{
"event_id": "cam-and-prompt",
"event": {
"prompt": "should-not-replace",
"multi_modal_data": {
"camera": {
"mode": "velocity",
"data": {
"translation": [0.0, 0.0, 0.05],
"rotation": [0.0, 0.0, 0.0, 1.0],
},
},
},
},
},
),
)
pipeline.encode_prompt.assert_not_called() # pyright: ignore[reportAttributeAccessIssue]
pending = session.pending_event
assert pending is not None
assert pending.event_id == "prior"
assert pending.prompt == "already-queued"
assert "camera" not in state.interaction_sessions
@pytest.mark.parametrize("model_class_name", ["HeliosPipeline", "HeliosPyramidPipeline"])
def test_coordinator_registers_prompt_for_helios_aliases(
self,
@@ -367,7 +446,11 @@ class TestPromptUpdateExecution:
f"that use the chunk media timeline, got {fps!r} "
f"(request_id={state.request_id!r})"
)
return ChunkMediaSpec(num_frames=int(state.extra["window_num_frames"]), fps=float(fps))
return ChunkMediaSpec(
num_media_frames=int(state.extra["window_num_frames"]),
fps=float(fps),
num_latent_frames=int(state.extra["window_num_frames"]),
)
def prepare_encode(self, state: StepRequestState) -> StepRequestState:
state.prompt_embeds = torch.zeros(1, 4, 2)
@@ -780,6 +780,65 @@ class TestRunner:
assert runner.pipeline.denoise_calls == 0
assert runner.state_cache == {}
def test_peer_prepare_encode_failure_skips_interaction_session_initialization(self, monkeypatch):
"""One rank's failure in ``_prepare_batch_inputs -> prepare_encode`` must be handled before all ranks initialize interaction sessions.
``maybe_prepare_initial_session`` calls ``synchronized_monotonic_time()``
(a broadcast). If this rank's ``prepare_encode`` succeeded while a peer
failed, entering that broadcast while the peer enters the failure
all-reduce would hang.
"""
from unittest.mock import MagicMock
from vllm_omni.diffusion.interaction.types import ChunkMediaSpec, InteractionChunkMetadata
class _InteractionStepPipeline(_StepPipeline):
def __init__(self):
super().__init__()
self.prepare_next_chunk_calls = 0
def peek_chunk_media(self, state):
del state
return ChunkMediaSpec(num_media_frames=8, fps=16.0, num_latent_frames=8)
def apply_interaction_at_chunk_boundary(self, state):
del state
def prepare_next_chunk(self, state):
del state
self.prepare_next_chunk_calls += 1
runner = _make_runner()
pipeline = _InteractionStepPipeline()
runner.pipeline = pipeline
coordinator = MagicMock()
coordinator.maybe_prepare_initial_session.return_value = InteractionChunkMetadata(
started_event_ids=[],
active_event_ids=[],
completed_event_ids=[],
)
runner._interaction_coordinator = coordinator
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
# Peer failed during prepare_encode; this rank's local encode succeeds.
monkeypatch.setattr(
model_runner_module,
"_dit_any_rank_failed",
lambda local_failed: True,
)
req = _make_step_request(num_inference_steps=1)
result = DiffusionModelRunner.execute_stepwise(runner, _make_scheduler_output(req))
output = result.get_request_output("req-1")
assert output.finished is True
assert output.result is not None
assert "another DiT rank" in (output.result.error or "")
assert pipeline.prepare_calls == 1
coordinator.maybe_prepare_initial_session.assert_not_called()
assert pipeline.prepare_next_chunk_calls == 0
assert pipeline.denoise_calls == 0
assert runner.state_cache == {}
def test_receives_kv_payload_before_prepare_encode(self, monkeypatch):
runner = _make_runner()
captured: dict[str, object] = {}
@@ -959,6 +959,76 @@ class TestStreamingVideoOutputPromptUpdate:
},
)
def test_composite_prompt_and_camera_interaction_forwarded(self, mocker: MockerFixture):
"""Prompt + camera multi_modal_data is admitted and forwarded to 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,
)
camera_payload = {
"mode": "target",
"data": {"translation": [0.0, 0.0, 1.0]},
}
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": "combo-1",
"event": {
"prompt": "new scene",
"multi_modal_data": {"camera": camera_payload},
},
"transition_chunks": 2,
},
}
)
queued = ws.receive_json()
assert queued["type"] == "session.interaction.queued"
assert queued["event_id"] == "combo-1"
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": "combo-1",
"event": {
"prompt": "new scene",
"multi_modal_data": {"camera": camera_payload},
},
"transition_chunks": 2,
},
)
class TestStreamingVideoOutputExtraParams:
"""extra_params handling when building streaming generation inputs."""
+87 -8
View File
@@ -11,8 +11,9 @@ from vllm_omni.diffusion.interaction.types import (
InteractionChunkMetadata,
InteractionPayload,
merge_interaction_metadata,
synchronized_monotonic_time,
)
from vllm_omni.diffusion.models.interface import supports_interaction_apply
from vllm_omni.diffusion.models.interface import SupportsInteractionApply, supports_interaction_apply
from vllm_omni.diffusion.worker.utils import StepRequestState
if TYPE_CHECKING:
@@ -52,7 +53,7 @@ class InteractionCoordinator:
@property
def needs_chunk_media(self) -> bool:
"""Whether any registered handler needs chunk ``num_frames``/``fps``."""
"""Whether any registered handler needs chunk media/latent counts + fps."""
return any(handler.needs_chunk_media for handler in self._handlers.values())
def get_handler(self, modality: str) -> InteractionHandler:
@@ -83,31 +84,109 @@ class InteractionCoordinator:
transition_chunks=transition_chunks,
)
def enqueue_parts(
self,
state: StepRequestState,
*,
parts: list[tuple[str, InteractionPayload]],
event_id: str,
received_at: float,
transition_chunks: int | None,
) -> None:
"""Ensure every modality is supported and every payload validates, then enqueue.
Composite events must not partially mutate queues when a later track is
unsupported or carries an invalid payload.
"""
if not parts:
raise ValueError("interaction event requires prompt and/or multi_modal_data")
# Reconcile rank-local samples so USP/SP shards share one arrival time.
received_at = synchronized_monotonic_time(received_at)
handlers = [(self.get_handler(modality), payload) for modality, payload in parts]
for handler, payload in handlers:
handler.validate_payload(
state,
event_id=event_id,
payload=payload,
transition_chunks=transition_chunks,
)
for (modality, payload), (handler, _) in zip(parts, handlers, strict=True):
del handler
self.enqueue(
state,
modality=modality,
event_id=event_id,
received_at=received_at,
payload=payload,
transition_chunks=transition_chunks,
)
def maybe_prepare_initial_session(
self,
state: StepRequestState,
pipeline: SupportsInteractionApply,
) -> InteractionChunkMetadata:
"""Materialize non-lazy modality sessions before chunk 0 denoise.
Handlers with ``lazy_initialize_session=False`` (e.g. camera) must exist
before the first chunk even with no client enqueue.
"""
num_media_frames: int | None = None
fps: float | None = None
num_latent_frames: int | None = None
if any(
(not handler.lazy_initialize_session) and handler.needs_chunk_media for handler in self._handlers.values()
):
media = pipeline.peek_chunk_media(state)
num_media_frames = media.num_media_frames
fps = media.fps
num_latent_frames = media.num_latent_frames
boundary_at = synchronized_monotonic_time()
chunk_index = state.chunk_index
metas: list[InteractionChunkMetadata] = []
for handler in self._handlers_in_apply_order():
if handler.lazy_initialize_session:
continue
meta = handler.apply_at_chunk_boundary(
state,
boundary_at=boundary_at,
chunk_index=chunk_index,
num_media_frames=num_media_frames,
fps=fps,
num_latent_frames=num_latent_frames,
)
if meta is not None:
metas.append(meta)
return merge_interaction_metadata(metas)
def apply_at_chunk_boundary(
self,
state: StepRequestState,
*,
boundary_at: float,
chunk_index: int | None = None,
num_frames: int | None = None,
num_media_frames: int | None = None,
fps: float | None = None,
num_latent_frames: int | None = None,
) -> InteractionChunkMetadata:
"""Fan out chunk-boundary apply to handlers in stable order (prompt first)."""
if chunk_index is None:
chunk_index = state.chunk_index
metas: list[InteractionChunkMetadata] = []
for handler in self._handlers_in_apply_order():
if handler.modality not in state.interaction_sessions:
# This function is always called at chunk boundary without knowing if an interaction is really enqueued.
# `state.interaction_sessions[this modality]` is lazy-populated after the first interaction is enqueued.
# So, check here and skip a modality handler if this modality's session is not yet created.
if handler.lazy_initialize_session and handler.modality not in state.interaction_sessions:
# Lazy modalities skip apply until the first enqueue creates the session.
continue
meta = handler.apply_at_chunk_boundary(
state,
boundary_at=boundary_at,
chunk_index=chunk_index,
num_frames=num_frames,
num_media_frames=num_media_frames,
fps=fps,
num_latent_frames=num_latent_frames,
)
if meta is not None:
metas.append(meta)
+8 -5
View File
@@ -4,9 +4,9 @@
from __future__ import annotations
import time
from typing import TYPE_CHECKING, cast
from vllm_omni.diffusion.interaction.types import synchronized_monotonic_time
from vllm_omni.diffusion.models.interface import SupportsInteractionApply
from vllm_omni.diffusion.worker.utils import StepRequestState
@@ -35,18 +35,21 @@ class InteractionMixin:
"onto the pipeline before chunked generation"
)
num_frames: int | None = None
num_media_frames: int | None = None
fps: float | None = None
num_latent_frames: int | None = None
if self._interaction_coordinator.needs_chunk_media:
media = cast(SupportsInteractionApply, self).peek_chunk_media(state)
num_frames = media.num_frames
num_media_frames = media.num_media_frames
fps = media.fps
num_latent_frames = media.num_latent_frames
merged = self._interaction_coordinator.apply_at_chunk_boundary(
state,
boundary_at=time.monotonic(),
num_frames=num_frames,
boundary_at=synchronized_monotonic_time(),
num_media_frames=num_media_frames,
fps=fps,
num_latent_frames=num_latent_frames,
)
state.interaction_chunk_metadata = merged
@@ -5,7 +5,9 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import ClassVar, Self
from typing import ClassVar
from typing_extensions import Self
from vllm_omni.diffusion.interaction.types import (
InteractionChunkMetadata,
@@ -23,9 +25,14 @@ class InteractionHandler(ABC):
"""
modality: ClassVar[str]
# When True, chunk-boundary apply needs ``ChunkMediaSpec`` (num_frames/fps)
# When True, chunk-boundary apply needs ``ChunkMediaSpec`` (frame count & fps)
# Those information are useful when interaction handler needs interpolation/integration on a frame-by-frame basis
needs_chunk_media: ClassVar[bool] = False
# When True, the coordinator skips ``apply_at_chunk_boundary`` until a session
# exists (normally created on the first enqueue). When False, every chunk
# boundary runs apply even with no prior enqueue so the handler can create
# its session and materialize default chunk data (e.g. identity camera hold).
lazy_initialize_session: ClassVar[bool] = True
@classmethod
def from_pipeline(cls, pipeline: object) -> Self:
@@ -37,6 +44,21 @@ class InteractionHandler(ABC):
del pipeline
return cls()
@abstractmethod
def validate_payload(
self,
state: StepRequestState,
*,
event_id: str,
payload: InteractionPayload,
transition_chunks: int | None,
) -> None:
"""Validate a modality payload without mutating request state.
Used by ``InteractionCoordinator.enqueue_parts`` so composite events
fail before any track is queued.
"""
@abstractmethod
def enqueue(
self,
@@ -56,7 +78,9 @@ class InteractionHandler(ABC):
*,
boundary_at: float,
chunk_index: int | None = None, # defaults to state.chunk_index when omitted
num_frames: int | None = None, # only set when at least one interaction handler needs it
fps: float | None = None, # only set when at least one interaction handler needs it
# Optional chunk media information. Only present when at least one initialized interaction handler needs it
num_media_frames: int | None = None,
fps: float | None = None,
num_latent_frames: int | None = None,
) -> InteractionChunkMetadata | None:
"""Advance request-local state and materialize this chunk's effects."""
@@ -0,0 +1,490 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Camera modality interaction handlers.
Payload is structural SE(3): ``translation`` (xyz) + unit
quaternion ``rotation`` (x, y, z, w). WASD-style key tokens belong in
clients, not in the engine.
"""
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import ClassVar, cast
import torch
from typing_extensions import Self, override
from vllm_omni.diffusion.interaction.modality_handlers.base import InteractionHandler
from vllm_omni.diffusion.interaction.types import (
InteractionChunkMetadata,
InteractionEvent,
InteractionMode,
InteractionPayload,
InteractionSession,
resolve_event_frame_offset,
)
from vllm_omni.diffusion.worker.utils import StepRequestState
Vec3 = tuple[float, float, float]
# Unit quaternion in ``(x, y, z, w)`` order. Identity is ``(0, 0, 0, 1)``.
Quat = tuple[float, float, float, float]
_IDENTITY_QUAT: Quat = (0.0, 0.0, 0.0, 1.0)
@dataclass
class CameraPose:
"""Canonical absolute camera pose used by the shared timeline.
Coordinates follow Unity semantics: ``+X`` right, ``+Y`` up,
``+Z`` forward. Session start is the identity transform;
``target`` poses are relative to that origin.
"""
translation: Vec3 = (0.0, 0.0, 0.0)
rotation: Quat = _IDENTITY_QUAT
@classmethod
def identity(cls) -> CameraPose:
return cls()
def clone(self) -> CameraPose:
return CameraPose(translation=self.translation, rotation=self.rotation)
def as_matrix(self) -> torch.Tensor:
"""Return a 4x4 rigid transform from translation + unit quaternion."""
mat = torch.eye(4, dtype=torch.float64)
mat[:3, :3] = _quat_to_rotmat(self.rotation)
mat[0, 3] = float(self.translation[0])
mat[1, 3] = float(self.translation[1])
mat[2, 3] = float(self.translation[2])
return mat
@classmethod
def from_matrix(cls, matrix: torch.Tensor) -> CameraPose:
if matrix.shape != (4, 4):
raise ValueError("camera pose matrix must be 4x4")
rotation = _rotmat_to_quat(matrix[:3, :3].detach().cpu().double())
translation = (
float(matrix[0, 3]),
float(matrix[1, 3]),
float(matrix[2, 3]),
)
return cls(translation=translation, rotation=rotation)
@dataclass(kw_only=True)
class QueuedCameraEvent(InteractionEvent):
"""Timestamped camera command waiting for the next chunk boundary."""
# Absolute pose for ``target``; per-frame SE3 delta for ``velocity``.
pose: CameraPose = field(default_factory=CameraPose.identity)
# Integer frame progress for ``target`` lerps (avoids float reciprocal drift).
elapsed_transition_frames: int = 0
@dataclass
class CameraSession(InteractionSession):
"""Request-local camera timeline under ``state.interaction_sessions['camera']``."""
current_pose: CameraPose = field(default_factory=CameraPose.identity)
pending_events: list[QueuedCameraEvent] = field(default_factory=list)
# In-flight command; ``elapsed_transition_chunks`` lives on the event.
active_event: QueuedCameraEvent | None = None
target_source: CameraPose | None = None
# Absolute C2W poses ``[num_latent_frames, 4, 4]`` for the most recent chunk.
last_absolute_poses: torch.Tensor | None = None
# Since a camera session is not lazily initialized, use this field to mark that a client enqueue is ever received.
has_received_input: bool = False
class SE3DeltaCameraHandler(InteractionHandler):
"""Generic camera timeline that samples absolute poses per frame.
* ``mode=target``: lerp toward an absolute pose (relative to session start).
* ``mode=velocity``: hold a structural SE3 delta until replaced.
``received_at`` is resolved on the media grid (``num_media_frames``/``fps``), then mapped onto latent steps.
Later integration and ``last_absolute_poses`` aligns with ``num_latent_frames``.
"""
modality: ClassVar[str] = "camera"
needs_chunk_media: ClassVar[bool] = True
# Camera conditioning must exist every chunk; apply creates the session and
# materializes identity/hold poses before any client enqueue.
lazy_initialize_session: ClassVar[bool] = False
default_transition_chunks: ClassVar[int] = 1
@classmethod
@override
def from_pipeline(cls, pipeline: object) -> Self:
"""It doesn't need anything from the pipeline (e.g., `encode_prompt` method)"""
del pipeline
return cls()
@override
def validate_payload(
self,
state: StepRequestState,
*,
event_id: str,
payload: InteractionPayload,
transition_chunks: int | None,
) -> None:
if not event_id:
raise ValueError("event_id must be non-empty")
mode = payload.get("mode", "target")
if mode not in ("target", "velocity"):
raise ValueError("camera mode must be 'target' or 'velocity'")
data = payload.get("data")
if not isinstance(data, Mapping):
raise ValueError("camera data must be an object")
if "actions" in data:
raise ValueError(
"camera data.actions (WASD keys) is not accepted by the engine; "
"send structural translation/rotation instead"
)
_parse_pose(data)
if mode == "target":
duration = self.default_transition_chunks if transition_chunks is None else int(transition_chunks)
if duration < 0:
raise ValueError("transition_chunks must be >= 0")
del state
@override
def enqueue(
self,
state: StepRequestState,
*,
event_id: str,
received_at: float,
payload: InteractionPayload,
transition_chunks: int | None,
) -> None:
self.validate_payload(
state,
event_id=event_id,
payload=payload,
transition_chunks=transition_chunks,
)
mode = payload.get("mode", "target")
camera_mode = cast(InteractionMode, mode)
data = cast(Mapping[str, object], payload.get("data"))
pose = _parse_pose(data)
if camera_mode == "target":
duration = self.default_transition_chunks if transition_chunks is None else int(transition_chunks)
else:
duration = 0
session = state.interaction_sessions.setdefault("camera", CameraSession())
assert isinstance(session, CameraSession)
with session.lock:
session.has_received_input = True
session.pending_events.append(
QueuedCameraEvent(
event_id=event_id,
received_at=received_at,
mode=camera_mode,
transition_chunks=duration,
pose=pose,
)
)
@override
def apply_at_chunk_boundary(
self,
state: StepRequestState,
*,
boundary_at: float,
chunk_index: int | None = None,
num_media_frames: int | None = None,
fps: float | None = None,
num_latent_frames: int | None = None,
) -> InteractionChunkMetadata | None:
"""Advance camera state for one chunk and store latent-aligned poses.
.. note::
Hardcoded the following policy of per-frame alignment:
- Resolve ``received_at`` on the media grid, then map each media index onto a latent step.
- Integrate one velocity step per **latent frame**, so holding speed ``0.05`` for a chunk with
``num_latent_frames=3`` yields total travel ``0.15``.
- ``last_absolute_poses.shape[0] == num_latent_frames``: interpolation is latent-aligned.
If future models need different scale/alignment policies, can refactor interaction registry
to be able to mark a policy.
"""
del chunk_index
if num_media_frames is None or fps is None:
raise ValueError("SE3DeltaCameraHandler requires chunk num_media_frames and fps")
if num_latent_frames is None:
num_latent_frames = num_media_frames
if int(num_latent_frames) <= 0:
raise ValueError(f"num_latent_frames must be > 0, got {num_latent_frames}")
session = state.interaction_sessions.setdefault("camera", CameraSession())
assert isinstance(session, CameraSession)
with session.lock:
poses, started, active, completed = self._step_one_chunk(
session,
num_media_frames=num_media_frames,
fps=fps,
num_latent_frames=num_latent_frames,
boundary_at=boundary_at,
)
if poses:
session.last_absolute_poses = torch.stack([p.as_matrix() for p in poses], dim=0)
else:
session.last_absolute_poses = torch.zeros((0, 4, 4), dtype=torch.float64)
return InteractionChunkMetadata(
started_event_ids=started,
active_event_ids=active,
completed_event_ids=completed,
)
def _step_one_chunk(
self,
session: CameraSession,
*,
num_media_frames: int,
fps: float,
num_latent_frames: int,
boundary_at: float,
) -> tuple[list[CameraPose], list[str], list[str], list[str]]:
"""Sample absolute poses for this chunk under target/velocity semantics."""
num_media_frames = max(int(num_media_frames), 1)
num_latent_frames = max(int(num_latent_frames), 1)
pending = list(session.pending_events)
session.pending_events.clear()
by_latent: dict[int, list[QueuedCameraEvent]] = {}
for event in pending:
media_frame = resolve_event_frame_offset(
received_at=event.received_at,
previous_boundary_at=session.last_boundary_at,
num_frames=num_media_frames,
fps=fps,
)
latent_idx = _media_frame_to_latent(
media_frame,
num_media_frames=num_media_frames,
num_latent_frames=num_latent_frames,
)
by_latent.setdefault(latent_idx, []).append(event)
for events in by_latent.values():
events.sort(key=lambda item: item.received_at)
started: list[str] = []
completed: list[str] = []
seen_completed: set[str] = set()
poses: list[CameraPose] = []
def _mark_completed(event_id: str) -> None:
if event_id not in seen_completed:
seen_completed.add(event_id)
completed.append(event_id)
for latent_idx in range(num_latent_frames):
for event in by_latent.get(latent_idx, []):
started.append(event.event_id)
cancelled_id = self._activate_event(session, event)
if cancelled_id is not None:
_mark_completed(cancelled_id)
just_completed = self._step_one_frame(session, num_latent_frames)
poses.append(session.current_pose.clone())
if just_completed is not None:
_mark_completed(just_completed)
active: list[str] = []
if session.active_event is not None:
active.append(session.active_event.event_id)
session.last_boundary_at = boundary_at
return poses, started, active, completed
def _activate_event(self, session: CameraSession, event: QueuedCameraEvent) -> str | None:
"""Activate ``event``, returning the event_id cancelled by this replacement if any."""
cancelled_id: str | None = None
if session.active_event is not None and session.active_event.event_id != event.event_id:
cancelled_id = session.active_event.event_id
event.elapsed_transition_chunks = 0.0
event.elapsed_transition_frames = 0
session.active_event = event
if event.mode == "target":
session.target_source = session.current_pose.clone()
else:
session.target_source = None
return cancelled_id
def _clear_active_target(self, session: CameraSession) -> str:
"""Snap to the target pose, clear the active target track, and return its event_id."""
assert session.active_event is not None and session.active_event.mode == "target"
event = session.active_event
event_id = event.event_id
session.current_pose = event.pose.clone()
session.active_event = None
session.target_source = None
return event_id
def _step_one_frame(self, session: CameraSession, total_num_frames_this_chunk: int) -> str | None:
"""Advance the active camera command by one frame."""
event = session.active_event
if event is None:
return None
if event.mode == "target":
duration = event.transition_chunks
source = session.target_source or session.current_pose
target = event.pose
if duration <= 0:
return self._clear_active_target(session)
total_frames = max(1, int(round(duration * float(total_num_frames_this_chunk))))
event.elapsed_transition_frames += 1
event.elapsed_transition_chunks = event.elapsed_transition_frames / float(total_num_frames_this_chunk)
alpha = min(1.0, event.elapsed_transition_frames / float(total_frames))
session.current_pose = _lerp_pose(source, target, alpha)
if event.elapsed_transition_frames >= total_frames:
return self._clear_active_target(session)
return None
# Velocity: apply the held structural SE3 delta once per frame.
session.current_pose = _compose_pose(session.current_pose, event.pose)
return None
def _media_frame_to_latent(
media_frame: int,
*,
num_media_frames: int,
num_latent_frames: int,
) -> int:
"""Map a media-timeline frame index onto the latent control grid."""
if num_latent_frames <= 1 or num_media_frames <= 1:
return 0
return min(int(media_frame) * int(num_latent_frames) // int(num_media_frames), num_latent_frames - 1)
def _as_xyz(value: object, *, name: str) -> Vec3:
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)) or len(value) != 3:
raise ValueError(f"camera {name} must be a length-3 list/tuple")
if any(math.isnan(v) or math.isinf(v) for v in value):
raise ValueError(f"camera {name} must be non-nan and non-inf")
return (float(value[0]), float(value[1]), float(value[2]))
def _as_quat(value: object, *, name: str) -> Quat:
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)) or len(value) != 4:
raise ValueError(f"camera {name} must be a length-4 list/tuple (x, y, z, w)")
if any(math.isnan(v) or math.isinf(v) for v in value):
raise ValueError(f"camera {name} must be non-nan and non-inf")
return _quat_normalize((float(value[0]), float(value[1]), float(value[2]), float(value[3])))
def _parse_pose(data: Mapping[str, object]) -> CameraPose:
translation = _as_xyz(data.get("translation", (0.0, 0.0, 0.0)), name="translation")
rotation = _as_quat(data.get("rotation", _IDENTITY_QUAT), name="rotation")
return CameraPose(translation=translation, rotation=rotation)
def _lerp(a: float, b: float, alpha: float) -> float:
return a + (b - a) * alpha
def _lerp_pose(source: CameraPose, target: CameraPose, alpha: float) -> CameraPose:
alpha = min(1.0, max(0.0, alpha))
return CameraPose(
translation=cast(
Vec3,
tuple(_lerp(s, t, alpha) for s, t in zip(source.translation, target.translation)),
),
rotation=_quat_nlerp(source.rotation, target.rotation, alpha),
)
def _compose_pose(base: CameraPose, delta: CameraPose) -> CameraPose:
"""Apply ``delta`` after ``base`` in the session frame (T_new = T_base @ T_delta)."""
return CameraPose.from_matrix(base.as_matrix() @ delta.as_matrix())
def _quat_normalize(q: Quat) -> Quat:
x, y, z, w = q
norm = math.sqrt(x * x + y * y + z * z + w * w)
if norm <= 0.0:
raise ValueError("camera rotation quaternion must be non-zero")
return (x / norm, y / norm, z / norm, w / norm)
def _quat_nlerp(a: Quat, b: Quat, alpha: float) -> Quat:
"""Normalized linear interpolation; flips ``b`` when needed for shortest path."""
dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]
if dot < 0.0:
b = (-b[0], -b[1], -b[2], -b[3])
return _quat_normalize(
(
_lerp(a[0], b[0], alpha),
_lerp(a[1], b[1], alpha),
_lerp(a[2], b[2], alpha),
_lerp(a[3], b[3], alpha),
)
)
def _quat_to_rotmat(q: Quat) -> torch.Tensor:
x, y, z, w = q
xx, yy, zz = x * x, y * y, z * z
xy, xz, yz = x * y, x * z, y * z
wx, wy, wz = w * x, w * y, w * z
return torch.tensor(
[
[1.0 - 2.0 * (yy + zz), 2.0 * (xy - wz), 2.0 * (xz + wy)],
[2.0 * (xy + wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz - wx)],
[2.0 * (xz - wy), 2.0 * (yz + wx), 1.0 - 2.0 * (xx + yy)],
],
dtype=torch.float64,
)
def _rotmat_to_quat(rotation: torch.Tensor) -> Quat:
"""Convert a 3x3 rotation matrix to a unit quaternion ``(x, y, z, w)``."""
m = rotation
trace = float(m[0, 0] + m[1, 1] + m[2, 2])
if trace > 0.0:
s = math.sqrt(trace + 1.0) * 2.0
w = 0.25 * s
x = (float(m[2, 1]) - float(m[1, 2])) / s
y = (float(m[0, 2]) - float(m[2, 0])) / s
z = (float(m[1, 0]) - float(m[0, 1])) / s
elif float(m[0, 0]) > float(m[1, 1]) and float(m[0, 0]) > float(m[2, 2]):
s = math.sqrt(1.0 + float(m[0, 0]) - float(m[1, 1]) - float(m[2, 2])) * 2.0
w = (float(m[2, 1]) - float(m[1, 2])) / s
x = 0.25 * s
y = (float(m[0, 1]) + float(m[1, 0])) / s
z = (float(m[0, 2]) + float(m[2, 0])) / s
elif float(m[1, 1]) > float(m[2, 2]):
s = math.sqrt(1.0 + float(m[1, 1]) - float(m[0, 0]) - float(m[2, 2])) * 2.0
w = (float(m[0, 2]) - float(m[2, 0])) / s
x = (float(m[0, 1]) + float(m[1, 0])) / s
y = 0.25 * s
z = (float(m[1, 2]) + float(m[2, 1])) / s
else:
s = math.sqrt(1.0 + float(m[2, 2]) - float(m[0, 0]) - float(m[1, 1])) * 2.0
w = (float(m[1, 0]) - float(m[0, 1])) / s
x = (float(m[0, 2]) + float(m[2, 0])) / s
y = (float(m[1, 2]) + float(m[2, 1])) / s
z = 0.25 * s
return _quat_normalize((x, y, z, w))
__all__ = [
"CameraPose",
"CameraSession",
"QueuedCameraEvent",
"SE3DeltaCameraHandler",
]
@@ -90,6 +90,7 @@ class PromptInteractionHandler(InteractionHandler):
"""
modality: ClassVar[str] = "prompt"
lazy_initialize_session: ClassVar[bool] = True
def __init__(
self,
@@ -113,16 +114,14 @@ class PromptInteractionHandler(InteractionHandler):
)
@override
def enqueue(
def validate_payload(
self,
state: StepRequestState,
*,
event_id: str,
received_at: float,
payload: InteractionPayload,
transition_chunks: int | None,
) -> None:
"""Prompt updates are last-write-win and unbuffered at chunk boundary."""
prompt = payload.get("prompt")
if not isinstance(prompt, str) or not prompt:
raise ValueError("prompt must be non-empty")
@@ -136,6 +135,27 @@ class PromptInteractionHandler(InteractionHandler):
if duration < 0:
raise ValueError("transition_chunks must be >= 0")
@override
def enqueue(
self,
state: StepRequestState,
*,
event_id: str,
received_at: float,
payload: InteractionPayload,
transition_chunks: int | None,
) -> None:
"""Prompt updates are last-write-win and unbuffered at chunk boundary."""
self.validate_payload(
state,
event_id=event_id,
payload=payload,
transition_chunks=transition_chunks,
)
prompt = payload.get("prompt")
assert isinstance(prompt, str)
duration = DEFAULT_TRANSITION_CHUNKS if transition_chunks is None else transition_chunks
target_prompt_embeds, _ = self._encode_prompt(
prompt=prompt,
negative_prompt=None,
@@ -164,12 +184,13 @@ class PromptInteractionHandler(InteractionHandler):
*,
boundary_at: float,
chunk_index: int | None = None,
num_frames: int | None = None,
num_media_frames: int | None = None,
fps: float | None = None,
num_latent_frames: int | None = None,
) -> InteractionChunkMetadata | None:
"""Advance or start prompt interpolation before the next chunk."""
# Prompt lerp is chunk-LWW; media timeline and caller chunk_index unused.
del chunk_index, num_frames, fps
del chunk_index, num_media_frames, fps, num_latent_frames
session = state.interaction_sessions.get("prompt")
assert isinstance(session, PromptSession)
+5 -1
View File
@@ -5,12 +5,13 @@ A pipeline only declares the modalities that it supports.
And a pipeline can choose which handler for each modality (there can be more than one handler for a modality).
Outer key: pipeline architecture name (``od_config.model_class_name``).
Inner key: modality name (e.g. ``\"prompt\"``).
Inner key: modality name (e.g. ``\"camera\"``).
"""
from __future__ import annotations
from vllm_omni.diffusion.interaction.modality_handlers.base import InteractionHandler
from vllm_omni.diffusion.interaction.modality_handlers.camera import SE3DeltaCameraHandler
from vllm_omni.diffusion.interaction.modality_handlers.prompt import PromptInteractionHandler
STRUCTURED_HANDLER_REGISTRY: dict[str, dict[str, type[InteractionHandler]]] = {
@@ -21,4 +22,7 @@ STRUCTURED_HANDLER_REGISTRY: dict[str, dict[str, type[InteractionHandler]]] = {
"HeliosPyramidPipeline": {
"prompt": PromptInteractionHandler,
},
"LingBotWorldCausalDMDPipeline": {
"camera": SE3DeltaCameraHandler,
},
}
+71 -8
View File
@@ -1,38 +1,75 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Shared types for midway interaction handlers."""
from __future__ import annotations
import math
import time
from collections.abc import Mapping
from dataclasses import dataclass, field
from threading import Lock
from typing import Literal
import torch
# Wire payload for one modality track. Handlers validate keys they need.
InteractionPayload = Mapping[str, object]
# Shared command semantics across modalities that support them.
# Prompt specialization uses ``"target"`` only.
InteractionMode = Literal["target"]
# Prompt specialization narrows this to ``"target"`` only.
InteractionMode = Literal["target", "velocity"]
def synchronized_monotonic_time(stamp: float | None = None) -> float:
"""Return a monotonic timestamp shared across DiT ranks.
``submit_interaction`` and chunk-boundary apply run as collectives, but
``time.monotonic()`` is rank-local. Under USP/SP, inter-rank skew that
straddles a ``1/fps`` bucket edge would schedule the same event onto
different frames per shard. Rank 0's stamp is broadcast (callers may pass
a local sample; non-source ranks' values are ignored).
"""
if stamp is None:
stamp = time.monotonic()
if not torch.distributed.is_initialized():
return stamp
try:
from vllm_omni.diffusion.distributed.parallel_state import get_world_group
group = get_world_group()
except Exception:
return stamp
if group.world_size <= 1:
return stamp
return group.broadcast_object(stamp, src=0)
@dataclass(frozen=True)
class ChunkMediaSpec:
"""Media extent of one generation chunk, for interaction timeline mapping."""
"""Decoded media extent and latent step count for one generation chunk.
num_frames: int
Attributes:
num_media_frames: Number of frames in this chunk when decoded into playable media.
fps: Frames per second of the media.
num_latent_frames: Number of latent frames in this chunk (internal DiT units).
"""
num_media_frames: int
fps: float
num_latent_frames: int
def __post_init__(self) -> None:
if self.num_frames <= 0:
raise ValueError(f"ChunkMediaSpec.num_frames must be > 0, got {self.num_frames}")
if self.num_media_frames <= 0:
raise ValueError(f"ChunkMediaSpec.num_media_frames must be > 0, got {self.num_media_frames}")
if self.num_latent_frames <= 0:
raise ValueError(f"ChunkMediaSpec.num_latent_frames must be > 0, got {self.num_latent_frames}")
if self.fps <= 0:
raise ValueError(f"ChunkMediaSpec.fps must be > 0, got {self.fps}")
@property
def duration_s(self) -> float:
return float(self.num_frames) / float(self.fps)
return float(self.num_media_frames) / float(self.fps)
@dataclass(kw_only=True)
@@ -41,6 +78,7 @@ class InteractionEvent:
Example:
- ``QueuedPromptEvent`` in ``vllm_omni.diffusion.interaction.modality_handlers.prompt``
- ``QueuedCameraEvent`` in ``vllm_omni.diffusion.interaction.modality_handlers.camera``
"""
event_id: str
@@ -60,12 +98,37 @@ class InteractionSession:
Example:
- ``PromptSession`` in ``vllm_omni.diffusion.interaction.modality_handlers.prompt``
- ``CameraSession`` in ``vllm_omni.diffusion.interaction.modality_handlers.camera``
"""
lock: Lock = field(default_factory=Lock, repr=False)
last_boundary_at: float | None = None
def resolve_event_frame_offset(
*,
received_at: float,
previous_boundary_at: float | None,
num_frames: int,
fps: float,
) -> int:
"""Map an event arrival time onto ``[0, num_frames - 1]`` for this chunk.
* Uses ``floor((received_at - previous_boundary_at) * fps)``.
* Events without a prior boundary (or with non-positive fps) map to frame 0.
* Offsets past the represented media window are clamped to the final frame.
"""
num_frames = max(int(num_frames), 1)
if previous_boundary_at is None or fps <= 0:
return 0
offset = math.floor((received_at - previous_boundary_at) * float(fps))
if offset < 0:
return 0
if offset >= num_frames:
return num_frames - 1
return int(offset)
@dataclass
class InteractionChunkMetadata:
"""Per-modality (or merged) event-id bookkeeping for one chunk boundary."""
@@ -535,19 +535,23 @@ class HeliosPipeline(
"zero_steps": int(extra.get("zero_steps", 1)),
}
)
self.prepare_next_chunk(state)
return state
def peek_chunk_media(self, state: StepRequestState) -> ChunkMediaSpec:
"""Expose this chunk's decoded media extent for interaction timelines."""
num_frames = int(state.extra["window_num_frames"])
num_media_frames = int(state.extra["window_num_frames"])
num_latent_frames = int(state.extra["num_latent_frames_per_chunk"])
fps = state.sampling.fps
if fps is None or float(fps) <= 0:
raise ValueError(
"sampling.fps is required and must be > 0 for interaction modalities that use the "
f"chunk media timeline, got {fps!r} (request_id={state.request_id!r})"
)
return ChunkMediaSpec(num_frames=num_frames, fps=float(fps))
return ChunkMediaSpec(
num_media_frames=num_media_frames,
fps=float(fps),
num_latent_frames=num_latent_frames,
)
@override
def prepare_next_chunk(self, state: StepRequestState) -> None:
+2 -2
View File
@@ -151,10 +151,10 @@ class SupportsInteractionApply(Protocol):
"""Optional protocol for pipelines with unified mid-generation, chunk-boundary hooks."""
def peek_chunk_media(self, state: StepRequestState) -> ChunkMediaSpec:
"""Return the media timeline represented by the upcoming/current chunk.
"""Return the media timeline and latent frame count for the upcoming/current chunk.
Useful when interaction handler needs interpolation/integration on a frame-by-frame basis,
or for backpressure/pacing.
and/or for backpressure/pacing.
"""
...
@@ -25,6 +25,7 @@ from vllm_omni.experimental.ar_diffusion.tick_protocol import (
LINGBOT_CAMERA_ACTION_SCHEMA = "lingbot.camera_actions.v1"
LINGBOT_CAMERA_TRAJECTORY_SCHEMA = "lingbot.camera_trajectory.v1"
LINGBOT_CONTROLLER_TRANSLATION_UNIT = 0.05
_ACTION_ORDER = ("w", "a", "s", "d", "i", "j", "k", "l")
_VALID_ACTIONS = frozenset(_ACTION_ORDER)
_REFERENCE_HEIGHT = 480
@@ -282,7 +283,7 @@ def integrate_lingbot_camera_actions(
"""Convert latent-frame WASD/IJKL controls to cumulative C2W poses.
Calibration and motion constants intentionally match SGLang's LingBot
adapter: movement 0.05, pitch 4 degrees, yaw 6 degrees, pitch clamp 85
adapter: movement 0.05 (LINGBOT_CONTROLLER_TRANSLATION_UNIT), pitch 4 degrees, yaw 6 degrees, pitch clamp 85
degrees, and target-resolution focal lengths of 500 pixels.
"""
@@ -325,13 +326,31 @@ def integrate_lingbot_camera_actions(
right /= right_norm + 1e-6
movement = np.zeros(3, dtype=np.float64)
movement += forward * 0.05 * (("w" in actions) - ("s" in actions))
movement += right * 0.05 * (("d" in actions) - ("a" in actions))
movement += forward * LINGBOT_CONTROLLER_TRANSLATION_UNIT * (("w" in actions) - ("s" in actions))
movement += right * LINGBOT_CONTROLLER_TRANSLATION_UNIT * (("d" in actions) - ("a" in actions))
# Keep the cumulative controller pose in FP64 between chunks, matching
# SGLang's NumPy integration and avoiding long-session drift.
current_pose = np.eye(4, dtype=np.float64)
current_pose[:3, :3] = new_rotation
current_pose[:3, 3] = translation + movement
poses.append(current_pose)
trajectory = camera_trajectory_from_absolute_pose(torch.from_numpy(np.stack(poses)), width=width, height=height)
return trajectory, current_pitch
def camera_trajectory_from_absolute_pose(
poses: torch.Tensor,
*,
width: int,
height: int,
) -> CameraTrajectory:
if width <= 0 or height <= 0:
raise ValueError("camera action resolution must be positive.")
if poses.ndim != 3 or poses.shape[-2:] != (4, 4):
raise ValueError(f"absolute camera poses must have shape [frames, 4, 4], got {tuple(poses.shape)}.")
if poses.shape[0] == 0:
raise ValueError("absolute camera poses must not be empty.")
# build_plucker_embedding expects intrinsics in the 832x480 reference
# coordinate system. These values become SGLang's [500, 500, W/2, H/2]
# after that function scales them to the requested resolution.
@@ -342,9 +361,7 @@ def integrate_lingbot_camera_actions(
_REFERENCE_HEIGHT / 2,
)
trajectory = CameraTrajectory(
# Keep the cumulative controller pose in FP64 between chunks, matching
# SGLang's NumPy integration and avoiding long-session drift.
poses=torch.from_numpy(np.stack(poses)),
poses=poses,
intrinsics=torch.tensor(reference_intrinsics, dtype=torch.float32).repeat(len(poses), 1),
)
return trajectory, current_pitch
return trajectory
@@ -389,7 +389,11 @@ def _invert_camera_poses(poses: torch.Tensor) -> torch.Tensor:
return inverse
def _prepare_framewise_poses(raw_poses: torch.Tensor) -> torch.Tensor:
def _prepare_framewise_poses(
raw_poses: torch.Tensor,
*,
translation_scale: float | None = None,
) -> torch.Tensor:
"""Convert raw C2W poses to normalized first-relative framewise deltas."""
# Remove global placement, then convert the sequence to framewise deltas.
@@ -402,12 +406,19 @@ def _prepare_framewise_poses(raw_poses: torch.Tensor) -> torch.Tensor:
relative_poses[1:],
)
# Only relative direction/magnitude matters to the checkpoint; normalizing
# by the largest step removes the trajectory's arbitrary translation scale.
# Normalizing the control's scale. Different between scripted and realtime input.
translations = relative_poses[:, :3, 3]
max_translation_norm = torch.linalg.vector_norm(translations, dim=-1).max()
if max_translation_norm > 0:
relative_poses[:, :3, 3] = translations / max_translation_norm
if translation_scale is None:
# Scripted action_path input: divide by the largest step so an arbitrary recording scale becomes 1.
scale = torch.linalg.vector_norm(translations, dim=-1).max()
else:
# Realtime interaction input: use the controller translation unit (one WASD step = full speed).
# It cannot see future motion or normalize globally.
if translation_scale <= 0:
raise ValueError(f"translation_scale must be > 0, got {translation_scale}")
scale = translation_scale
if float(scale) > 0:
relative_poses[:, :3, 3] = translations / scale
return relative_poses
@@ -420,6 +431,7 @@ def build_plucker_embedding(
target_width: int,
device: torch.device,
dtype: torch.dtype,
translation_scale: float | None = None,
) -> torch.Tensor:
"""Build checkpoint-ordered ``(ray origin, ray direction)`` channels."""
@@ -438,7 +450,10 @@ def build_plucker_embedding(
# Compute geometry in at least FP32, then cast at the model boundary.
compute_dtype = torch.float32 if dtype in (torch.float16, torch.bfloat16) else dtype
poses = _prepare_framewise_poses(trajectory.poses.to(device=device, dtype=compute_dtype))
poses = _prepare_framewise_poses(
trajectory.poses.to(device=device, dtype=compute_dtype),
translation_scale=translation_scale,
)
intrinsics = trajectory.intrinsics.to(device=device, dtype=compute_dtype).clone()
intrinsics[:, (0, 2)] *= width / _REFERENCE_WIDTH
intrinsics[:, (1, 3)] *= height / _REFERENCE_HEIGHT
@@ -26,16 +26,21 @@ from vllm.model_executor.models.utils import AutoWeightsLoader
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import DistributedAutoencoderKLWan
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.interaction.mixin import InteractionMixin
from vllm_omni.diffusion.interaction.modality_handlers.camera import CameraSession
from vllm_omni.diffusion.interaction.types import ChunkMediaSpec
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.model_loader.hub_prefetch import from_pretrained_with_prefetch, prefetch_subfolders
from vllm_omni.diffusion.models.interface import SupportImageInput, SupportsComponentDiscovery, SupportsStepExecution
from vllm_omni.diffusion.models.lingbot_world.actions import (
LINGBOT_CAMERA_ACTION_SCHEMA,
LINGBOT_CAMERA_TRAJECTORY_SCHEMA,
LINGBOT_CONTROLLER_TRANSLATION_UNIT,
LingBotCameraActionFrames,
LingBotCameraActionScript,
as_camera_action_frames,
as_camera_action_script,
camera_trajectory_from_absolute_pose,
integrate_lingbot_camera_actions,
parse_lingbot_camera_action_frames,
parse_lingbot_camera_action_script,
@@ -385,25 +390,36 @@ def get_lingbot_world_pre_process_func(
camera_actions = None
else:
action_path = extra_args.get("action_path")
if not isinstance(action_path, (str, os.PathLike)) or not str(action_path):
raise ValueError("action_path is required in sampling_params.extra_args.action_path.")
if not configured_action_root:
raise ValueError(
"sampling_params.extra_args.action_path requires a trusted action root configured by "
f"model_config.lingbot_action_root or {_ACTION_ROOT_ENV}."
if action_path is None or action_path == "":
# Stepwise / streaming sessions may omit a request-scoped camera
# script and instead drive motion via mid-generation camera
# interaction (idle hold until the first camera event).
if getattr(od_config, "step_execution", False) or getattr(od_config, "streaming_output", False):
trajectory = None
camera_actions = None
camera_action_script = None
else:
raise ValueError("action_path is required in sampling_params.extra_args.action_path.")
else:
if not isinstance(action_path, (str, os.PathLike)) or not str(action_path):
raise ValueError("action_path is required in sampling_params.extra_args.action_path.")
if not configured_action_root:
raise ValueError(
"sampling_params.extra_args.action_path requires a trusted action root configured by "
f"model_config.lingbot_action_root or {_ACTION_ROOT_ENV}."
)
action_directory = resolve_trusted_action_directory(
action_path,
configured_action_root,
)
action_directory = resolve_trusted_action_directory(
action_path,
configured_action_root,
)
try:
trajectory = load_camera_trajectory(action_directory)
except OSError:
raise ValueError(
"Unable to load camera trajectory from action_path; expected poses.npy and intrinsics.npy."
) from None
camera_actions = None
camera_action_script = None
try:
trajectory = load_camera_trajectory(action_directory)
except OSError:
raise ValueError(
"Unable to load camera trajectory from action_path; expected poses.npy and intrinsics.npy."
) from None
camera_actions = None
camera_action_script = None
updated_prompt = dict(prompt)
updated_multi_modal_data = dict(multi_modal_data)
@@ -460,6 +476,7 @@ class LingBotWorldCausalDMDPipeline(
SupportImageInput,
SupportsComponentDiscovery,
SupportsStepExecution,
InteractionMixin,
ProgressBarMixin,
DiffusionPipelineProfilerMixin,
):
@@ -779,8 +796,10 @@ class LingBotWorldCausalDMDPipeline(
camera_sources = (
int(camera_trajectory is not None) + int(camera_actions is not None) + int(camera_action_script is not None)
)
if camera_sources != 1:
raise ValueError("LingBot pre-processing must materialize exactly one camera input.")
if camera_sources > 1:
raise ValueError("LingBot pre-processing must materialize at most one camera input.")
# Zero camera sources are allowed for stepwise camera-interaction sessions;
# request-mode ``forward`` still requires an explicit trajectory or actions.
request_flow_shift = (
extra_args["flow_shift"] if "flow_shift" in extra_args else getattr(self.scheduler.config, "shift", 5.0)
@@ -1047,7 +1066,7 @@ class LingBotWorldCausalDMDPipeline(
*,
dtype: torch.dtype,
previous: CameraTrajectory | None = None,
latent_aligned: bool = False,
latent_aligned: bool = False, # iff using realtime interaction inputs
) -> tuple[torch.Tensor, CameraTrajectory]:
"""Convert raw camera frames to a latent-aligned ray tensor."""
@@ -1083,10 +1102,10 @@ class LingBotWorldCausalDMDPipeline(
),
)
drop_anchor = True
elif inputs.camera_actions is not None:
# The action integrator returns post-action poses. Keep the first
# action visible to framewise-delta conditioning by prepending its
# known pre-action state (identity for a new realtime session).
elif inputs.camera_actions is not None or latent_aligned:
# Action/interaction integrators return post-action poses. Prepend the
# known pre-action state (identity for a new session) so the first
# pose still contributes a framewise delta.
identity = torch.eye(
4,
device=trajectory.poses.device,
@@ -1100,6 +1119,8 @@ class LingBotWorldCausalDMDPipeline(
),
)
drop_anchor = True
# Realtime interaction cannot see future steps; use the controller translation unit to normalize speed.
# Full action_path keeps max-norm.
camera_embedding = build_plucker_embedding(
embedding_trajectory,
height=inputs.height,
@@ -1108,6 +1129,7 @@ class LingBotWorldCausalDMDPipeline(
target_width=inputs.width,
device=self.device,
dtype=dtype,
translation_scale=LINGBOT_CONTROLLER_TRANSLATION_UNIT if latent_aligned else None,
)
if drop_anchor:
camera_embedding = camera_embedding[1:]
@@ -1277,6 +1299,11 @@ class LingBotWorldCausalDMDPipeline(
"request mode takes one three-frame camera_actions control per "
"block, or a camera_trajectory for the request."
)
if inputs.camera_trajectory is None and inputs.camera_actions is None:
raise ValueError(
"LingBot request mode requires camera_trajectory or camera_actions; "
"omit them only for stepwise camera-interaction sessions."
)
tick = ARDiffusionTickRequest.from_extra_args(req.sampling_params.extra_args)
if tick is not None and self._ar_diffusion_kv_state is None:
raise RuntimeError("LingBot typed ticks require ARDiffusionEngine session binding.")
@@ -1645,11 +1672,6 @@ class LingBotWorldCausalDMDPipeline(
}
self._ar_text_caches(prompt_embeds, invalidate=False)
self._ar_sessions[state.request_id] = _LingBotARSessionState()
try:
self._prepare_next_chunk(state)
except Exception:
self._release_condition_encoder_state(state.request_id)
raise
return state
def _prepare_next_chunk(self, state: StepRequestState) -> None:
@@ -1668,7 +1690,14 @@ class LingBotWorldCausalDMDPipeline(
dtype=extra["dtype"],
)
previous = extra.get("camera_tail")
if extra.get("camera_action_script") is not None:
if (
(camera_session := state.interaction_sessions.get("camera"))
and isinstance(camera_session, CameraSession)
and camera_session.has_received_input
):
raise ValueError("Cannot use mid-generation camera interaction together with camera_action_script; ")
chunk_actions = extra["camera_action_script"][state.chunk_index]
action_trajectory, camera_pitch = integrate_lingbot_camera_actions(
chunk_actions,
@@ -1678,11 +1707,12 @@ class LingBotWorldCausalDMDPipeline(
initial_pitch=float(extra.get("camera_pitch", 0.0)),
)
extra["camera_pitch"] = camera_pitch
media_frames = self._chunk_media_frame_count(state, block_frames=block_frames)
chunk_inputs = replace(
inputs,
camera_trajectory=action_trajectory,
camera_actions=chunk_actions,
num_frames=(block_frames - 1) * self.vae_scale_factor_temporal + 1,
num_frames=media_frames,
num_latent_frames=block_frames,
)
camera, camera_tail = self._prepare_camera(
@@ -1691,14 +1721,53 @@ class LingBotWorldCausalDMDPipeline(
previous=previous,
)
elif extra.get("camera_embedding_cache") is not None:
# Same slice request mode takes from its one full-trajectory
# embedding, so both paths condition a block identically.
if (
(camera_session := state.interaction_sessions.get("camera"))
and isinstance(camera_session, CameraSession)
and camera_session.has_received_input
):
raise ValueError("Cannot use mid-generation camera interaction together with camera_embedding_cache; ")
trajectory = extra["camera_trajectory_cache"]
camera = extra["camera_embedding_cache"][:, :, start_frame:stop_frame]
camera_tail = CameraTrajectory(
poses=trajectory.poses[stop_frame - 1 : stop_frame].clone(),
intrinsics=trajectory.intrinsics[stop_frame - 1 : stop_frame].clone(),
)
elif self._interaction_coordinator is not None and self._interaction_coordinator.has_modality("camera"):
# Poses come from the latest boundary apply: ``prepare_encode`` for
# chunk 0, the diffusion runner for later chunks. This method only
# digests them — it does not apply interactions itself.
camera_session = state.interaction_sessions.get("camera")
if not isinstance(camera_session, CameraSession) or camera_session.last_absolute_poses is None:
raise RuntimeError(
"LingBot camera interaction requires apply_interaction_at_chunk_boundary before prepare_next_chunk."
)
absolute_poses = camera_session.last_absolute_poses
media_frames = self._chunk_media_frame_count(state, block_frames=block_frames)
num_latent_frames = block_frames
if int(absolute_poses.shape[0]) != num_latent_frames:
raise ValueError(
"camera interaction must produce exactly one pose per latent frame; "
f"got {int(absolute_poses.shape[0])}, expected {num_latent_frames}."
)
action_trajectory = camera_trajectory_from_absolute_pose(
absolute_poses,
width=inputs.width,
height=inputs.height,
)
chunk_inputs = replace(
inputs,
camera_trajectory=action_trajectory,
camera_actions=None,
num_frames=media_frames,
num_latent_frames=num_latent_frames,
)
camera, camera_tail = self._prepare_camera(
chunk_inputs,
dtype=extra["dtype"],
previous=previous,
latent_aligned=True,
)
else:
raise RuntimeError("LingBot step execution is missing a camera trajectory or action script.")
if camera.shape[2:] != condition.shape[2:]:
@@ -1842,19 +1911,48 @@ class LingBotWorldCausalDMDPipeline(
session_state.pending_encoder_cache = None
session_state.next_chunk_index += 1
state.chunk_index += 1
finished = state.request_denoise_completed
if not finished:
self._prepare_next_chunk(state)
else:
self._release_condition_encoder_state(state.request_id)
return DiffusionOutput(
output=output,
stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None,
chunk_index=completed_chunk_index,
total_chunks=state.total_chunks,
finished=finished,
finished=state.request_denoise_completed,
)
def _chunk_media_frame_count(self, state: StepRequestState, *, block_frames: int) -> int:
"""Pixel frames this chunk will emit when decoded.
Independent (non-streaming) decode restarts causal expansion every block,
so each chunk is ``(block_frames - 1) * temporal + 1`` frames.
Streaming decode expands only the session's opening latent to one frame and every later latent to the full
temporal factor, so chunk 0 matches the independent count and later chunks are ``block_frames * temporal``.
"""
decode_state = self._streaming_decode_states.get(state.request_id)
if decode_state is not None and decode_state.started:
return block_frames * self.vae_scale_factor_temporal
return (block_frames - 1) * self.vae_scale_factor_temporal + 1
def peek_chunk_media(self, state: StepRequestState) -> ChunkMediaSpec:
"""Expose this chunk's decoded media extent and latent step count."""
num_latent_frames = int(state.extra.get("block_frames") or self.transformer.config.num_frames_per_block)
num_media_frames = self._chunk_media_frame_count(state, block_frames=num_latent_frames)
fps = state.sampling.fps
if fps is None or float(fps) <= 0:
# Media-frame camera controls are not wall-clock paced; a unit fps keeps
# resolve_event_frame_offset well-defined when the client omits sampling.fps.
fps = float(num_media_frames)
return ChunkMediaSpec(
num_media_frames=num_media_frames,
fps=fps,
num_latent_frames=num_latent_frames,
)
def prepare_next_chunk(self, state: StepRequestState) -> None:
"""Prepare the next AR block after chunk-boundary interaction apply."""
if state.request_denoise_completed:
return
self._prepare_next_chunk(state)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
loader = AutoWeightsLoader(self)
return cast(set[str], loader.load_weights(weights))
@@ -43,6 +43,7 @@ from vllm_omni.diffusion.diffusion_kv.paged_attention_adapter import (
from vllm_omni.diffusion.distributed.parallel_state import get_classifier_free_guidance_rank
from vllm_omni.diffusion.forward_context import set_forward_context
from vllm_omni.diffusion.interaction.coordinator import InteractionCoordinator
from vllm_omni.diffusion.interaction.types import InteractionPayload
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.models.interface import (
SupportsInteractionApply,
@@ -1080,30 +1081,13 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
error_outputs: list[RunnerOutput] = []
for state in states:
if state.request_id in new_request_ids:
# Everything that runs before ``_dit_any_rank_failed`` must be
# inside the try: an exception in ``_initialize_generator`` or
# Everything that requires rank-synchronization must be called
# inside a try, record the exception and handle with `_dit_any_rank_failed`.
# Reason (example): An exception in ``_initialize_generator`` or
# ``clear_pipeline_stage_durations`` on one rank would skip the
# all-reduce here while every peer proceeds into it, and the
# peers then hang on the NCCL collective until timeout.
per_req_exc: BaseException | None = None
try:
self._initialize_generator(state.sampling)
clear_pipeline_stage_durations(pipeline)
# encode
pipeline.prepare_encode(state)
merge_stage_durations(
state,
consume_pipeline_stage_durations(pipeline),
)
except Exception as exc:
per_req_exc = exc
# Pipelines that do rank-0-only work (e.g. MiniMax H3
# reference-video prep) must broadcast per-request failures
# internally so downstream collectives stay in step; even so,
# cross-check that every DiT rank agrees so a rank-local error
# (or a future pipeline that omits the guard) does not leave
# the process group half-way through a new request.
if _dit_any_rank_failed(per_req_exc is not None):
def _abort_prep_failure(per_req_exc: BaseException | None) -> None:
self.state_cache.pop(state.request_id, None)
if per_req_exc is None:
per_req_exc = RuntimeError(
@@ -1123,6 +1107,41 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
result=DiffusionOutput.from_exception(per_req_exc),
)
)
per_req_exc: BaseException | None = None
try:
self._initialize_generator(state.sampling)
clear_pipeline_stage_durations(pipeline)
pipeline.prepare_encode(state)
except Exception as exc:
per_req_exc = exc
# Pipelines that do rank-0-only work (e.g. MiniMax H3
# reference-video prep) must broadcast per-request failures
# internally so downstream collectives stay in step; even so,
# cross-check that every DiT rank agrees so a rank-local error
# (or a future pipeline that omits the guard) does not leave
# the process group half-way through a new request.
if _dit_any_rank_failed(per_req_exc is not None):
_abort_prep_failure(per_req_exc)
continue
# If the pipeline supports interaction, the interaction session initialization also needs to call
# synchronized_monotonic_time(). Wrap in another try-block to not block on prepare_encode failures.
try:
if supports_interaction_apply(pipeline) and state.chunk_index == 0:
pipe = cast(SupportsInteractionApply, pipeline)
assert self._interaction_coordinator is not None, "Model not loaded. Call load_model() first."
state.interaction_chunk_metadata = self._interaction_coordinator.maybe_prepare_initial_session(
state, pipe
)
pipe.prepare_next_chunk(state)
merge_stage_durations(
state,
consume_pipeline_stage_durations(pipeline),
)
except Exception as exc:
per_req_exc = exc
if _dit_any_rank_failed(per_req_exc is not None):
_abort_prep_failure(per_req_exc)
continue
prepared_states.append(state)
@@ -1431,47 +1450,33 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
interaction: OmniInteractionPrompt,
) -> None:
"""Route a midway interaction through the pipeline interaction coordinator."""
assert self.pipeline is not None, "Model not loaded. Call load_model() first."
assert self.pipeline is not None and self._interaction_coordinator 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")
coordinator = self._interaction_coordinator
if coordinator is None:
coordinator = InteractionCoordinator.build(self.pipeline, self.od_config)
self._interaction_coordinator = coordinator
if hasattr(self.pipeline, "_interaction_coordinator"):
self.pipeline._interaction_coordinator = coordinator
event = interaction.get("event")
has_mm = isinstance(event, dict) and "multi_modal_data" in event
has_prompt = isinstance(event, dict) and "prompt" in event and event.get("prompt") is not None
# Prompt-only interactions in this release; multi_modal_data lands with camera support.
if not isinstance(event, dict) or has_mm or not has_prompt:
raise NotImplementedError(
"Only text-only prompt update interactions with 'event.prompt' and optional "
"'transition_chunks' are supported in this release"
)
if not coordinator.has_modality("prompt"):
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 interaction: {request_id!r}")
event_id = interaction.get("event_id")
if not isinstance(event_id, str) or not event_id:
raise ValueError("event_id must be non-empty")
prompt = event["prompt"]
if not isinstance(prompt, str) or not prompt:
raise ValueError("prompt must be non-empty")
coordinator.enqueue(
event = interaction["event"]
parts: list[tuple[str, InteractionPayload]] = []
prompt = event.get("prompt")
if prompt is not None:
parts.append(("prompt", {"prompt": prompt}))
multi_modal_data = event.get("multi_modal_data")
if multi_modal_data:
parts.extend(
(str(modality), cast(InteractionPayload, payload)) for modality, payload in multi_modal_data.items()
)
self._interaction_coordinator.enqueue_parts(
state,
modality="prompt",
event_id=event_id,
parts=parts,
event_id=interaction["event_id"],
received_at=time.monotonic(),
payload={"prompt": prompt},
transition_chunks=interaction.get("transition_chunks"),
)
@@ -362,15 +362,41 @@ class OmniStreamingVideoOutputHandler:
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():
multi_modal_data = event.get("multi_modal_data")
has_prompt = isinstance(prompt, str) and bool(prompt.strip())
has_mm = isinstance(multi_modal_data, dict) and bool(multi_modal_data)
if not has_prompt and not has_mm:
await self._send_error(
websocket,
"session.interaction requires a non-empty event.prompt",
"session.interaction requires a non-empty event.prompt and/or non-empty event.multi_modal_data",
send_lock=send_lock,
)
return
if "prompt" in event and prompt is not None and not has_prompt:
await self._send_error(
websocket,
"session.interaction event.prompt must be a non-empty string when provided",
send_lock=send_lock,
)
return
if "multi_modal_data" in event and multi_modal_data is not None and not has_mm:
await self._send_error(
websocket,
"session.interaction event.multi_modal_data must be a non-empty object when provided",
send_lock=send_lock,
)
return
if has_mm:
assert isinstance(multi_modal_data, dict)
for modality, payload in multi_modal_data.items():
if not isinstance(payload, dict):
await self._send_error(
websocket,
f"session.interaction multi_modal_data[{modality!r}] must be an object",
send_lock=send_lock,
)
return
if "negative_prompt" in event and event["negative_prompt"] is not None:
await self._send_error(
@@ -380,9 +406,19 @@ class OmniStreamingVideoOutputHandler:
)
return
normalized_event: dict[str, Any] = {}
if has_prompt:
normalized_event["prompt"] = prompt
if has_mm:
normalized_event["multi_modal_data"] = multi_modal_data
# Preserve other event fields that OmniInteractionEvent may carry.
for key, value in event.items():
if key not in normalized_event and key not in ("prompt", "multi_modal_data"):
normalized_event[key] = value
normalized: OmniInteractionPrompt = {
"event_id": event_id,
"event": cast(OmniInteractionEvent, event),
"event": cast(OmniInteractionEvent, normalized_event),
}
transition_chunks = interaction.get("transition_chunks")
if transition_chunks is not None:
@@ -572,8 +608,11 @@ class OmniStreamingVideoOutputHandler:
@staticmethod
async def _decode_image_reference(request: VideoGenerationRequest) -> Image.Image | None:
try:
image_reference = request.image_reference
if isinstance(image_reference, list):
raise InvalidInputReferenceError("image_reference must be a single image reference.")
media_data = await decode_input_reference(
request.image_reference,
image_reference,
None,
None,
)