[Bugfix][Qwen3-VL] Fix multi-video crash with list-valued fps/num_frames (#46305)

Signed-off-by: Ting Sun <suntcrick@gmail.com>

Ting SUN committed Jun 22, 2026 at 04:31 UTC 12fe2a9aac8e0284ff1dfbd53857f7e6f7f50da1
2 files changed +59 -2
tests/models/multimodal/processing/test_qwen3_vl.py
+46
@@ -138,3 +138,49 @@ def test_processor_multi_video(
138 assert video_phs[i].offset >= prev_end, (
139 f"Placeholder {i} overlaps with placeholder {i - 1}"
140 )
141 +
142 +
143 +@pytest.mark.parametrize("model_id", [MODEL_ID])
144 +@pytest.mark.parametrize(
145 + "hf_mm_kwargs",
146 + [{"num_frames": [8, 16]}, {"fps": [2.0, 4.0]}],
147 +)
148 +def test_processor_multi_video_list_kwargs(
149 + model_id: str,
150 + hf_mm_kwargs: dict[str, Any],
151 +) -> None:
152 + """Regression test: a multi-video request with list-valued per-video
153 + ``mm_processor_kwargs`` (one ``fps``/``num_frames`` per video) must not
154 + crash.
155 +
156 + Before the fix, ``_call_hf_processor`` copied the whole kwargs to every
157 + video without slicing, so ``_get_video_second_idx`` received the list
158 + where a scalar was expected and raised ``TypeError``.
159 + """
160 + ctx = build_model_context(
161 + model_id,
162 + limit_mm_per_prompt={"image": 0, "video": 2},
163 + )
164 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
165 +
166 + prompt = (
167 + "<|vision_start|><|video_pad|><|vision_end|>"
168 + "<|vision_start|><|video_pad|><|vision_end|>"
169 + )
170 + mm_data = {
171 + "video": [
172 + _build_video_mm_data(num_frames=16)["video"][0],
173 + _build_video_mm_data(num_frames=32)["video"][0],
174 + ]
175 + }
176 +
177 + processed = processor(
178 + prompt,
179 + mm_items=processor.info.parse_mm_data(mm_data),
180 + hf_processor_mm_kwargs=hf_mm_kwargs,
181 + )
182 +
183 + video_phs = processed["mm_placeholders"].get("video", [])
184 + assert len(video_phs) == 2, (
185 + f"Expected exactly 2 video placeholders, got {len(video_phs)}"
186 + )
vllm/model_executor/models/qwen3_vl.py
+13 -2
@@ -1271,7 +1271,7 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1271 vision_end_token_id = hf_config.vision_end_token_id
1272 video_token_id = hf_config.video_token_id
1273
1274 - for item in videos:
1274 + for item_idx, item in enumerate(videos):
1275 video_array, metadata = item
1276
1277 # NOTE: @JJJYmmm new attr metadata.frames_indices indicates
@@ -1282,6 +1282,12 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1282 # NOTE: a copy of is created to update do_sample_frames,
1283 # otherwise mm_hash for the object will be incorrect.
1284 video_mm_kwargs = dict(**mm_kwargs)
1285 + sampled_fps = video_mm_kwargs.get("fps")
1286 + if is_list_of(sampled_fps, float):
1287 + video_mm_kwargs["fps"] = sampled_fps[item_idx]
1288 + sampled_num_frames = video_mm_kwargs.get("num_frames")
1289 + if is_list_of(sampled_num_frames, int):
1290 + video_mm_kwargs["num_frames"] = sampled_num_frames[item_idx]
1291 if "do_sample_frames" not in video_mm_kwargs:
1292 # qwen_vl_utils already has "do_sample_frames" in
1293 # mm_kwargs, don't overwrite it.
@@ -1363,10 +1369,15 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1369 else:
1370 video_outputs = dict()
1371
1372 + # fps/num_frames are video-only kwargs already consumed by the loop;
1373 + # exclude them so the text/image processor call below never gets a list.
1374 + non_video_mm_kwargs = {
1375 + k: v for k, v in mm_kwargs.items() if k not in ("fps", "num_frames")
1376 + }
1377 processed_outputs = super()._call_hf_processor(
1378 prompt=prompt,
1379 mm_data=mm_data,
1369 - mm_kwargs=mm_kwargs,
1380 + mm_kwargs=non_video_mm_kwargs,
1381 tok_kwargs=tok_kwargs,
1382 )
1383