[Frontend] Add video embeds input support (#54242)
Signed-off-by: Isotr0py <Isotr0py@outlook.com>
Isotr0py committed
Aug 31, 2026 at 11:20 UTC
555ea65e8c41732e62eed93915f7ac6bf9497f6c
8 files changed
+461
-24
examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py
+26
-15
@@ -10,7 +10,7 @@ clusters:
10
• decode (language-model inference)
11
12
For MM input we:
13
- 1. Extract *every* image/audio item.
13
+ 1. Extract *every* image/audio/video item.
14
2. Fire N concurrent requests to the encoder cluster
15
(one request per item, with **all text removed**).
16
3. Wait for all of them to succeed.
@@ -61,7 +61,16 @@ encoder_rr_lock = asyncio.Lock()
61
###############################################################################
62
63
64
-MM_TYPES = {"image_url", "audio_url", "input_audio"}
64
+MM_TYPES = {"image_url", "audio_url", "input_audio", "video_url"}
65
+
66
+# The embeds content type each MM item is rewritten to once the encoder has
67
+# published its embedding out of band.
68
+EMBEDS_TYPES = {
69
+ "image_url": "image_embeds",
70
+ "audio_url": "audio_embeds",
71
+ "input_audio": "audio_embeds",
72
+ "video_url": "video_embeds",
73
+}
74
75
76
def encoder_rr_assignment(
@@ -94,7 +103,9 @@ def content_uuid(item: dict) -> str:
103
the unmodified path, which hashes the content, would keep it. That asymmetry
104
silently biases any comparison between the two.
105
"""
97
- url = (item.get("image_url") or item.get("audio_url") or {}).get("url") or ""
106
+ url = (
107
+ item.get("image_url") or item.get("audio_url") or item.get("video_url") or {}
108
+ ).get("url") or ""
109
payload = url or json.dumps(item, sort_keys=True)
110
return hashlib.sha256(payload.encode()).hexdigest()
111
@@ -103,19 +114,22 @@ def _b64_tensor(values: list) -> str:
114
import torch
115
116
buf = io.BytesIO()
106
- grid = torch.tensor(values, dtype=torch.long)
107
- # Downstream stacks per item, so hand over a flat (t, h, w).
108
- torch.save(grid.reshape(-1)[:3], buf)
117
+ flat = [v for item in values for v in (item if isinstance(item, list) else [item])]
118
+ # Floats stay float64 so timestamp strings format exactly as the
119
+ # encoder computed them.
120
+ dtype = torch.float64 if any(isinstance(v, float) for v in flat) else None
121
+ # Downstream stacks per item, so hand over a flat vector.
122
+ torch.save(torch.tensor(flat, dtype=dtype), buf)
123
return base64.b64encode(buf.getvalue()).decode()
124
125
126
def rewrite_for_decode(req_data: dict, item_meta: dict[int, dict]) -> dict:
113
- """Replace each image item with a metadata-only reference for the decoder.
127
+ """Replace each media item with a metadata-only reference for the decoder.
128
129
The decoder does not need the pixels: the encoder instance already produced
130
the embedding and published it through the EC connector under the same uuid.
131
Sending only the grid lets the decoder size the placeholder range without
118
- re-running the image transform.
132
+ re-running the media transform.
133
134
`item_meta` holds what the encoder reported for each item (its cache key and
135
the grid its processor actually produced), so the grid is never re-derived
@@ -146,25 +160,22 @@ def rewrite_for_decode(req_data: dict, item_meta: dict[int, dict]) -> dict:
160
# processor cache); let the decoder process the media itself.
161
new_content.append(item)
162
continue
163
+ embeds_type = EMBEDS_TYPES[item["type"]]
164
new_content.append(
150
- {
151
- "type": "image_embeds",
152
- "image_embeds": metadata,
153
- "uuid": item_uuid,
154
- }
165
+ {"type": embeds_type, embeds_type: metadata, "uuid": item_uuid}
166
)
167
rewritten += 1
168
new_messages.append({**msg, "content": new_content})
169
170
if not rewritten:
171
return req_data
161
- logger.info("Rewrote %d image item(s) as metadata references", rewritten)
172
+ logger.info("Rewrote %d media item(s) as metadata references", rewritten)
173
return {**req_data, "messages": new_messages}
174
175
176
def extract_mm_items(request_data: dict) -> list[dict]:
177
"""
167
- Return *all* image/audio items that appear anywhere in `messages`.
178
+ Return *all* image/audio/video items that appear anywhere in `messages`.
179
180
Each returned dict looks like:
181
{ "type": "image_url", "image_url": {...} }
tests/entrypoints/unit_tests/test_chat_utils.py
+137
@@ -98,6 +98,16 @@ def qwen25omni_model_config_image_embeds():
98
)
99
100
101
+@pytest.fixture(scope="function")
102
+def qwen25omni_model_config_video_embeds():
103
+ return ModelConfig(
104
+ QWEN25OMNI_MODEL_ID,
105
+ runner="generate",
106
+ limit_mm_per_prompt={"video": 2},
107
+ enable_mm_embeds=True,
108
+ )
109
+
110
+
111
@pytest.fixture(scope="function")
112
def qwen2_audio_model_config():
113
return ModelConfig(
@@ -969,6 +979,133 @@ async def test_parse_chat_messages_audio_embeds_async(
979
_assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[None])
980
981
982
+def test_parse_chat_messages_empty_video_embeds_with_uuid(
983
+ qwen25omni_model_config_video_embeds,
984
+):
985
+ """Test video_embeds with UUID (no actual embeds data)."""
986
+ uuid = "test-video-uuid-123"
987
+
988
+ conversation, mm_data, mm_uuids = parse_chat_messages(
989
+ [
990
+ {
991
+ "role": "user",
992
+ "content": [
993
+ {"type": "text", "text": "Describe this video"},
994
+ {"type": "video_embeds", "video_embeds": None, "uuid": uuid},
995
+ ],
996
+ }
997
+ ],
998
+ qwen25omni_model_config_video_embeds,
999
+ content_format="string",
1000
+ )
1001
+
1002
+ # Should have video in mm_data as None (UUID provided)
1003
+ assert mm_data is not None
1004
+ assert "video" in mm_data
1005
+ assert isinstance(mm_data["video"], list)
1006
+ assert len(mm_data["video"]) == 1
1007
+ assert mm_data["video"][0] is None
1008
+
1009
+ # UUID should be recorded
1010
+ _assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=[uuid])
1011
+
1012
+
1013
+def test_parse_chat_messages_video_embeds_with_string(
1014
+ qwen25omni_model_config_video_embeds,
1015
+):
1016
+ """Test video_embeds with base64 string embedding data."""
1017
+ hidden_size = qwen25omni_model_config_video_embeds.get_inputs_embeds_size()
1018
+ video_embedding = torch.randn(1, 128, hidden_size)
1019
+
1020
+ base64_video_embedding = tensor2base64(video_embedding)
1021
+
1022
+ conversation, mm_data, mm_uuids = parse_chat_messages(
1023
+ [
1024
+ {
1025
+ "role": "user",
1026
+ "content": [
1027
+ {"type": "text", "text": "Describe this video"},
1028
+ {
1029
+ "type": "video_embeds",
1030
+ "video_embeds": base64_video_embedding,
1031
+ },
1032
+ ],
1033
+ }
1034
+ ],
1035
+ qwen25omni_model_config_video_embeds,
1036
+ content_format="string",
1037
+ )
1038
+
1039
+ # Should have video embedding in mm_data (single tensor, not a list)
1040
+ assert mm_data is not None
1041
+ assert "video" in mm_data
1042
+ assert isinstance(mm_data["video"], torch.Tensor)
1043
+ assert mm_data["video"].shape == video_embedding.shape
1044
+ # No UUID provided
1045
+ assert mm_uuids is not None
1046
+ assert "video" in mm_uuids
1047
+ _assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=[None])
1048
+
1049
+
1050
+@pytest.mark.asyncio
1051
+async def test_parse_chat_messages_video_embeds_async(
1052
+ qwen25omni_model_config_video_embeds,
1053
+):
1054
+ """Test video_embeds with async futures."""
1055
+ hidden_size = qwen25omni_model_config_video_embeds.get_inputs_embeds_size()
1056
+ video_embedding = torch.randn(1, 128, hidden_size)
1057
+
1058
+ base64_video_embedding = tensor2base64(video_embedding)
1059
+
1060
+ conversation, mm_data, mm_uuids = await parse_chat_messages_async(
1061
+ [
1062
+ {
1063
+ "role": "user",
1064
+ "content": [
1065
+ {"type": "text", "text": "Describe this video"},
1066
+ {
1067
+ "type": "video_embeds",
1068
+ "video_embeds": base64_video_embedding,
1069
+ },
1070
+ ],
1071
+ }
1072
+ ],
1073
+ qwen25omni_model_config_video_embeds,
1074
+ content_format="string",
1075
+ )
1076
+
1077
+ # Should have video embedding in mm_data (single tensor, not a list)
1078
+ assert mm_data is not None
1079
+ assert "video" in mm_data
1080
+ assert isinstance(mm_data["video"], torch.Tensor)
1081
+ assert mm_data["video"].shape == video_embedding.shape
1082
+ # No UUID provided
1083
+ assert mm_uuids is not None
1084
+ assert "video" in mm_uuids
1085
+ _assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=[None])
1086
+
1087
+
1088
+def test_parse_chat_messages_video_and_video_embeds_mixed(
1089
+ qwen25omni_model_config_video_embeds,
1090
+ video_url,
1091
+):
1092
+ """Test that mixing raw video and video_embeds is rejected."""
1093
+ with pytest.raises(VLLMValidationError, match="Mixing raw video"):
1094
+ parse_chat_messages(
1095
+ [
1096
+ {
1097
+ "role": "user",
1098
+ "content": [
1099
+ {"type": "video_url", "video_url": {"url": video_url}},
1100
+ {"type": "video_embeds", "video_embeds": None, "uuid": "x"},
1101
+ ],
1102
+ }
1103
+ ],
1104
+ qwen25omni_model_config_video_embeds,
1105
+ content_format="string",
1106
+ )
1107
+
1108
+
1109
def test_parse_chat_messages_multiple_image_embeds(
1110
phi3v_model_config_image_embeds,
1111
):
tests/models/multimodal/processing/test_qwen3_vl.py
+85
@@ -11,8 +11,10 @@ from typing import Any
11
import numpy as np
12
import pytest
13
14
+from vllm.config import ModelConfig
15
from vllm.multimodal import MULTIMODAL_REGISTRY
16
17
+from ...registry import HF_EXAMPLE_MODELS
18
from ...utils import build_model_context
19
20
MODEL_ID = "Qwen/Qwen3-VL-4B-Instruct"
@@ -186,6 +188,89 @@ def test_processor_multi_video_list_kwargs(
188
)
189
190
191
+def _build_video_embeds_mm_data(
192
+ hidden_size: int,
193
+ grid_thw: tuple[int, int, int] = (2, 4, 4),
194
+) -> dict[str, Any]:
195
+ """Create an embeds-only video item as an EPD consumer receives it:
196
+ pre-computed embeddings plus the metadata published by the encoder."""
197
+ import torch
198
+
199
+ t, h, w = grid_thw
200
+ num_tokens = t * h * w // 4 # spatial_merge_size ** 2
201
+ return {
202
+ "video": {
203
+ "video_embeds": torch.zeros(num_tokens, hidden_size),
204
+ "video_grid_thw": torch.tensor([grid_thw]),
205
+ "timestamps": torch.tensor([[float(i) for i in range(t)]]),
206
+ }
207
+ }
208
+
209
+
210
+@pytest.mark.parametrize("model_id", [MODEL_ID])
211
+def test_processor_video_embeds_with_timestamps(model_id: str) -> None:
212
+ """Embeds-only video input must size the placeholder range from the
213
+ grid and the real timestamps published by the encoder (EC consumer
214
+ path); synthesized or missing timestamps would change the token count
215
+ and break embedding merging downstream."""
216
+ # `build_model_context` forces enable_mm_embeds off, so build the
217
+ # config directly; keep the same online-availability skip behavior.
218
+ HF_EXAMPLE_MODELS.find_hf_info(model_id).check_available_online(on_fail="skip")
219
+ model_config = ModelConfig(
220
+ model_id,
221
+ runner="generate",
222
+ limit_mm_per_prompt={"image": 0, "video": 1},
223
+ enable_mm_embeds=True,
224
+ )
225
+ processor = MULTIMODAL_REGISTRY.create_processor(model_config)
226
+ tokenizer = processor.info.get_tokenizer()
227
+
228
+ grid_thw = (2, 4, 4)
229
+ mm_data = _build_video_embeds_mm_data(
230
+ model_config.get_inputs_embeds_size(), grid_thw
231
+ )
232
+
233
+ prompt = "<|vision_start|><|video_pad|><|vision_end|>"
234
+ processed = processor(
235
+ prompt,
236
+ mm_items=processor.info.parse_mm_data(mm_data),
237
+ hf_processor_mm_kwargs={},
238
+ )
239
+
240
+ video_phs = processed["mm_placeholders"].get("video", [])
241
+ assert len(video_phs) == 1, (
242
+ f"Expected exactly 1 video placeholder, got {len(video_phs)}"
243
+ )
244
+
245
+ t, h, w = grid_thw
246
+ tokens_per_frame = h * w // 4
247
+ timestamp_tokens = sum(
248
+ len(tokenizer.encode(f"<{float(i):.1f} seconds>", add_special_tokens=False))
249
+ for i in range(t)
250
+ )
251
+ # per frame: timestamp tokens + vision_start + video tokens + vision_end
252
+ expected_len = timestamp_tokens + t * (tokens_per_frame + 2)
253
+ assert video_phs[0].length == expected_len
254
+
255
+
256
+@pytest.mark.parametrize("model_id", [MODEL_ID])
257
+def test_processor_video_embeds_missing_timestamps(model_id: str) -> None:
258
+ """Timestamps are required metadata for video embeds: they size the
259
+ placeholder range, so omitting them must fail loudly at parse time
260
+ instead of silently producing a wrong prompt."""
261
+ ctx = build_model_context(
262
+ model_id,
263
+ limit_mm_per_prompt={"image": 0, "video": 1},
264
+ )
265
+ processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
266
+
267
+ mm_data = _build_video_embeds_mm_data(ctx.model_config.get_inputs_embeds_size())
268
+ del mm_data["video"]["timestamps"]
269
+
270
+ with pytest.raises(ValueError, match="timestamps"):
271
+ processor.info.parse_mm_data(mm_data)
272
+
273
+
274
@pytest.mark.parametrize("model_id", [MODEL_ID])
275
def test_dummy_video_spreads_budget_when_frame_cap_enabled(model_id: str) -> None:
276
"""Regression test for memory profiling with ``cap_pixels_per_frame``.
vllm/distributed/ec_transfer/ec_connector/example_connector.py
+10
-5
@@ -14,6 +14,7 @@ from vllm.distributed.ec_transfer.ec_connector.base import (
14
ECConnectorRole,
15
)
16
from vllm.logger import init_logger
17
+from vllm.utils.collection_utils import is_list_of
18
from vllm.v1.core.sched.output import SchedulerOutput
19
20
if TYPE_CHECKING:
@@ -221,11 +222,15 @@ class ECExampleConnector(ECConnectorBase):
222
# back to processing the media itself.
223
if feature.data is not None:
224
wanted = self._placeholder_metadata_fields(feature.modality)
224
- metadata = {
225
- key: value.tolist()
226
- for key, value in feature.data.get_data().items()
227
- if key in wanted and isinstance(value, torch.Tensor)
228
- }
225
+ for key, value in feature.data.get_data().items():
226
+ if key not in wanted:
227
+ continue
228
+ if isinstance(value, torch.Tensor):
229
+ metadata[key] = value.tolist()
230
+ elif is_list_of(value, (int, float)):
231
+ # Some metadata (e.g. Qwen3-VL video timestamps) is
232
+ # produced as a plain list rather than a tensor.
233
+ metadata[key] = value
234
items.append({"mm_hash": feature.identifier, **metadata})
235
236
if not items:
vllm/entrypoints/chat_utils.py
+115
@@ -178,6 +178,22 @@ class ChatCompletionContentPartAudioEmbedsParam(TypedDict, total=False):
178
"""
179
180
181
+class ChatCompletionContentPartVideoEmbedsParam(TypedDict, total=False):
182
+ video_embeds: str | dict[str, str] | None
183
+ """
184
+ The video embeddings. It can be either:
185
+ - A single base64 string representing a serialized torch tensor.
186
+ - A dictionary where each value is a base64 string.
187
+ """
188
+ type: Required[Literal["video_embeds"]]
189
+ """The type of the content part."""
190
+ uuid: str | None
191
+ """
192
+ User-provided UUID of a media. User must guarantee that it is properly
193
+ generated and unique for different medias.
194
+ """
195
+
196
+
197
class ChatCompletionContentPartPromptEmbedsParam(TypedDict, total=False):
198
data: Required[str]
199
"""
@@ -324,6 +340,7 @@ ChatCompletionContentPartParam: TypeAlias = (
340
| CustomChatCompletionContentSimpleImageParam
341
| ChatCompletionContentPartImageEmbedsParam
342
| ChatCompletionContentPartAudioEmbedsParam
343
+ | ChatCompletionContentPartVideoEmbedsParam
344
| ChatCompletionContentPartPromptEmbedsParam
345
| CustomChatCompletionContentSimpleAudioParam
346
| CustomChatCompletionContentSimpleVideoParam
@@ -415,6 +432,7 @@ ModalityStr = Literal[
432
"video",
433
"image_embeds",
434
"audio_embeds",
435
+ "video_embeds",
436
"vision_chunk",
437
"prompt_embeds",
438
]
@@ -755,6 +773,11 @@ def _resolve_items(
773
"Mixing raw audio and embedding inputs is not allowed",
774
parameter="audio_embeds",
775
)
776
+ if "video" in items_by_modality and "video_embeds" in items_by_modality:
777
+ raise VLLMValidationError(
778
+ "Mixing raw video and embedding inputs is not allowed",
779
+ parameter="video_embeds",
780
+ )
781
# `prompt_embeds` bypasses HF MM processors. Every other modality requires one.
782
processor_modalities = items_by_modality.keys() - {"prompt_embeds"}
783
if processor_modalities and mm_processor is None:
@@ -789,6 +812,14 @@ def _resolve_items(
812
if "video" in items_by_modality:
813
mm_data["video"] = [data for data, uuid in items_by_modality["video"]]
814
mm_uuids["video"] = [uuid for data, uuid in items_by_modality["video"]]
815
+ if "video_embeds" in items_by_modality:
816
+ assert mm_processor is not None
817
+ mm_data["video"] = _get_embeds_data(
818
+ "video",
819
+ [data for data, uuid in items_by_modality["video_embeds"]],
820
+ mm_processor,
821
+ )
822
+ mm_uuids["video"] = [uuid for data, uuid in items_by_modality["video_embeds"]]
823
if "vision_chunk" in items_by_modality:
824
assert mm_processor is not None
825
# Process vision_chunk items - extract from (data, modality) tuples
@@ -941,6 +972,14 @@ class BaseMultiModalContentParser(ABC):
972
def parse_video(self, video_url: str | None, uuid: str | None = None) -> None:
973
raise NotImplementedError
974
975
+ @abstractmethod
976
+ def parse_video_embeds(
977
+ self,
978
+ video_embeds: str | dict[str, str] | None,
979
+ uuid: str | None = None,
980
+ ) -> None:
981
+ raise NotImplementedError
982
+
983
984
class MultiModalContentParser(BaseMultiModalContentParser):
985
def __init__(
@@ -1096,6 +1135,32 @@ class MultiModalContentParser(BaseMultiModalContentParser):
1135
audio_placeholder = self._tracker.add("audio", (audio, uuid))
1136
self._add_placeholder("audio", audio_placeholder)
1137
1138
+ def parse_video_embeds(
1139
+ self,
1140
+ video_embeds: str | dict[str, str] | None,
1141
+ uuid: str | None = None,
1142
+ ) -> None:
1143
+ mm_config = self.model_config.get_multimodal_config()
1144
+ if not mm_config.enable_mm_embeds:
1145
+ raise VLLMValidationError(
1146
+ "You must set `--enable-mm-embeds` to input `video_embeds`",
1147
+ parameter="video_embeds",
1148
+ )
1149
+
1150
+ if isinstance(video_embeds, dict):
1151
+ embeds = {
1152
+ k: self._connector.fetch_video_embedding(v)
1153
+ for k, v in video_embeds.items()
1154
+ }
1155
+ placeholder = self._tracker.add("video_embeds", (embeds, uuid))
1156
+ elif isinstance(video_embeds, str):
1157
+ embedding = self._connector.fetch_video_embedding(video_embeds)
1158
+ placeholder = self._tracker.add("video_embeds", (embedding, uuid))
1159
+ else:
1160
+ placeholder = self._tracker.add("video_embeds", (None, uuid))
1161
+
1162
+ self._add_placeholder("video", placeholder)
1163
+
1164
1165
class AsyncMultiModalContentParser(BaseMultiModalContentParser):
1166
def __init__(
@@ -1304,6 +1369,43 @@ class AsyncMultiModalContentParser(BaseMultiModalContentParser):
1369
)
1370
self._add_placeholder("audio", audio_placeholder)
1371
1372
+ def parse_video_embeds(
1373
+ self,
1374
+ video_embeds: str | dict[str, str] | None,
1375
+ uuid: str | None = None,
1376
+ ) -> None:
1377
+ mm_config = self.model_config.get_multimodal_config()
1378
+ if not mm_config.enable_mm_embeds:
1379
+ raise VLLMValidationError(
1380
+ "You must set `--enable-mm-embeds` to input `video_embeds`",
1381
+ parameter="video_embeds",
1382
+ )
1383
+
1384
+ placeholder = self._tracker.add(
1385
+ "video_embeds",
1386
+ partial(self._video_embeds_with_uuid_async, video_embeds, uuid),
1387
+ )
1388
+ self._add_placeholder("video", placeholder)
1389
+
1390
+ async def _video_embeds_with_uuid_async(
1391
+ self,
1392
+ video_embeds: str | dict[str, str] | None,
1393
+ uuid: str | None,
1394
+ ):
1395
+ if isinstance(video_embeds, dict):
1396
+ tensors = await asyncio.gather(
1397
+ *(
1398
+ self._connector.fetch_video_embedding_async(v)
1399
+ for v in video_embeds.values()
1400
+ )
1401
+ )
1402
+ embeds = dict(zip(video_embeds, tensors))
1403
+ elif isinstance(video_embeds, str):
1404
+ embeds = await self._connector.fetch_video_embedding_async(video_embeds)
1405
+ else:
1406
+ embeds = None
1407
+ return embeds, uuid
1408
+
1409
1410
@dataclass
1411
class ChatTemplateConfig:
@@ -1481,6 +1583,7 @@ def _get_full_multimodal_text_prompt(
1583
_TextParser = partial(cast, ChatCompletionContentPartTextParam)
1584
_ImageEmbedsParser = partial(cast, ChatCompletionContentPartImageEmbedsParam)
1585
_AudioEmbedsParser = partial(cast, ChatCompletionContentPartAudioEmbedsParam)
1586
+_VideoEmbedsParser = partial(cast, ChatCompletionContentPartVideoEmbedsParam)
1587
_PromptEmbedsParser = partial(cast, ChatCompletionContentPartPromptEmbedsParam)
1588
_InputAudioParser = partial(cast, ChatCompletionContentPartInputAudioParam)
1589
_RefusalParser = partial(cast, ChatCompletionContentPartRefusalParam)
@@ -1507,6 +1610,7 @@ MM_PARSER_MAP: dict[
1610
"image_url": lambda part: _ImageParser(part).get("image_url", {}).get("url", None),
1611
"image_embeds": lambda part: _ImageEmbedsParser(part).get("image_embeds", None),
1612
"audio_embeds": lambda part: _AudioEmbedsParser(part).get("audio_embeds", None),
1613
+ "video_embeds": lambda part: _VideoEmbedsParser(part).get("video_embeds", None),
1614
"prompt_embeds": lambda part: _PromptEmbedsParser(part).get("data", None),
1615
"image_pil": lambda part: _PILImageParser(part).get("image_pil", None),
1616
"audio_url": lambda part: _AudioParser(part).get("audio_url", {}).get("url", None),
@@ -1605,6 +1709,13 @@ def _parse_chat_message_content_mm_part(
1709
)
1710
audio_embeds = audio_params.get("audio_embeds", None)
1711
return "audio_embeds", audio_embeds
1712
+ if "video_embeds" in part:
1713
+ # "video_embeds" could be None if UUID is provided.
1714
+ video_embeds_params = cast( # type: ignore[assignment]
1715
+ ChatCompletionContentPartVideoEmbedsParam, part
1716
+ )
1717
+ video_embeds = video_embeds_params.get("video_embeds", None)
1718
+ return "video_embeds", video_embeds
1719
if "prompt_embeds" in part:
1720
prompt_embeds_params = cast( # type: ignore[assignment]
1721
ChatCompletionContentPartPromptEmbedsParam, part
@@ -1780,6 +1891,10 @@ def _parse_chat_message_content_part(
1891
content = cast(str | dict[str, str], content) if content is not None else None
1892
mm_parser.parse_audio_embeds(content, uuid)
1893
modality = "audio"
1894
+ elif part_type == "video_embeds":
1895
+ content = cast(str | dict[str, str], content) if content is not None else None
1896
+ mm_parser.parse_video_embeds(content, uuid)
1897
+ modality = "video"
1898
elif part_type == "prompt_embeds":
1899
if not content:
1900
raise VLLMValidationError(
vllm/model_executor/models/qwen3_vl.py
+15
-2
@@ -894,6 +894,19 @@ class Qwen3_VisionTransformer(nn.Module):
894
return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
895
896
897
+class Qwen3VLMultiModalDataParser(Qwen2VLMultiModalDataParser):
898
+ # Timestamps are part of the prompt replacement (each frame group's
899
+ # "<X.X seconds>" text), so they size the placeholder range and must
900
+ # travel with the grid when embeddings are delivered out of band.
901
+ embedding_fields = {
902
+ **Qwen2VLMultiModalDataParser.embedding_fields,
903
+ "video": {
904
+ **Qwen2VLMultiModalDataParser.embedding_fields["video"],
905
+ "timestamps": "metadata",
906
+ },
907
+ }
908
+
909
+
910
class Qwen3VLProcessingInfo(Qwen2VLProcessingInfo):
911
def get_hf_config(self):
912
return self.ctx.get_hf_config(Qwen3VLConfig)
@@ -912,7 +925,7 @@ class Qwen3VLProcessingInfo(Qwen2VLProcessingInfo):
925
return self.get_hf_processor(**kwargs).video_processor
926
927
def get_data_parser(self):
915
- return Qwen2VLMultiModalDataParser(
928
+ return Qwen3VLMultiModalDataParser(
929
self.get_hf_config().vision_config.spatial_merge_size,
930
video_needs_metadata=True,
931
expected_hidden_size=self._get_expected_hidden_size(),
@@ -1609,7 +1622,7 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1622
def get_video_repl(
1623
*,
1624
tokens_per_frame: list[int],
1612
- timestamps: list[float | int],
1625
+ timestamps: list[float | int] | torch.Tensor,
1626
tokenizer: TokenizerLike,
1627
vision_start_token_id: int,
1628
vision_end_token_id: int,
vllm/multimodal/media/connector.py
+26
-1
@@ -35,7 +35,7 @@ from vllm.utils.registry import ExtensionManager
35
from .audio import AudioEmbeddingMediaIO, AudioMediaIO
36
from .base import MediaIO, MediaWithBytes
37
from .image import ImageEmbeddingMediaIO, ImageMediaIO
38
-from .video import VideoMediaIO
38
+from .video import VideoEmbeddingMediaIO, VideoMediaIO
39
40
logger = init_logger(__name__)
41
@@ -653,3 +653,28 @@ class MediaConnector:
653
return await loop.run_in_executor(
654
global_thread_pool, audio_embedding_io.load_base64, "", data
655
)
656
+
657
+ def fetch_video_embedding(
658
+ self,
659
+ data: str,
660
+ ) -> torch.Tensor:
661
+ """
662
+ Load video embedding from a URL.
663
+ """
664
+ video_embedding_io = VideoEmbeddingMediaIO()
665
+
666
+ return video_embedding_io.load_base64("", data)
667
+
668
+ async def fetch_video_embedding_async(
669
+ self,
670
+ data: str,
671
+ ) -> torch.Tensor:
672
+ """
673
+ Asynchronously load video embedding from a URL.
674
+ """
675
+ video_embedding_io = VideoEmbeddingMediaIO()
676
+ loop = asyncio.get_running_loop()
677
+
678
+ return await loop.run_in_executor(
679
+ global_thread_pool, video_embedding_io.load_base64, "", data
680
+ )
vllm/multimodal/media/video.py
+47
-1
@@ -1,20 +1,24 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from functools import partial
4
+from io import BytesIO
5
from pathlib import Path
6
from typing import Any
7
8
import numpy as np
9
import numpy.typing as npt
10
import pybase64
11
+import torch
12
from PIL import Image
13
14
from vllm import envs
15
from vllm.logger import init_logger
16
+from vllm.utils.serial_utils import tensor2base64
17
+from vllm.utils.sparse_utils import check_sparse_tensor_invariants_threadsafe
18
19
from ..video import VIDEO_LOADER_REGISTRY
20
from .base import MediaIO, MediaWithBytes
17
-from .image import ImageMediaIO
21
+from .image import MAGIC_NUMPY_PREFIX, ImageMediaIO
22
23
logger = init_logger(__name__)
24
@@ -194,3 +198,45 @@ class VideoMediaIO(MediaIO[MediaWithBytes[tuple[npt.NDArray, dict[str, Any]]]]):
198
199
msg = "Only JPEG format is supported for now."
200
raise NotImplementedError(msg)
201
+
202
+
203
+class VideoEmbeddingMediaIO(MediaIO[torch.Tensor]):
204
+ """Video embedding MediaIO implementation.
205
+
206
+ Configuration values can be user-provided either by --media-io-kwargs or
207
+ by the runtime API field "media_io_kwargs". Ensure proper validation and
208
+ error handling.
209
+ """
210
+
211
+ def __init__(self) -> None:
212
+ super().__init__()
213
+
214
+ def _load_pickled_torch(self, data: bytes) -> torch.Tensor:
215
+ buffer = BytesIO(data)
216
+ with check_sparse_tensor_invariants_threadsafe():
217
+ tensor = torch.load(buffer, weights_only=True)
218
+ return tensor.to_dense()
219
+
220
+ def _load_numpy(self, data: bytes) -> torch.Tensor:
221
+ with BytesIO(data) as buffer:
222
+ return torch.from_numpy(np.load(buffer))
223
+
224
+ def load_bytes(self, data: bytes) -> torch.Tensor:
225
+ if data[:6] == MAGIC_NUMPY_PREFIX:
226
+ return self._load_numpy(data)
227
+
228
+ return self._load_pickled_torch(data)
229
+
230
+ def load_base64(self, media_type: str, data: str) -> torch.Tensor:
231
+ return self.load_bytes(pybase64.b64decode(data, validate=True))
232
+
233
+ def load_file(self, filepath: Path) -> torch.Tensor:
234
+ if filepath.suffix == ".npy":
235
+ return torch.from_numpy(np.load(filepath))
236
+
237
+ with check_sparse_tensor_invariants_threadsafe():
238
+ tensor = torch.load(filepath, weights_only=True)
239
+ return tensor.to_dense()
240
+
241
+ def encode_base64(self, media: torch.Tensor) -> str:
242
+ return tensor2base64(media)