Repository navigation
feat(models): video input for the Qwen VL families (Qwen3.5-VL / Qwen3.8, then the rest) #1166
Description
Summary
--video is rejected for every Qwen VL family with "--video input is currently only supported by Gemma 4 and Kimi-VL VLMs" (src/commands/generate_vlm.rs:447), even though the checkpoints ship a full video contract and upstream mlx-vlm supports video on this path end to end. Add video input for the Qwen VL families, starting with qwen3_5 (Qwen3.5-VL / Qwen3.8-27B).
The rejection is honest and fails closed, so this is a missing capability, not a correctness bug.
Evidence that the checkpoints expect video
mlx-community/Qwen3.8-27B-4bit (revision 3e6447f0) ships all of:
config.json:video_token_id: 248057,vision_start_token_id: 248053,vision_end_token_id: 248054video_preprocessor_config.json:video_processor_type: "Qwen3VLVideoProcessor",patch_size 16,temporal_patch_size 2,merge_size 2,size.longest_edge 25165824processor_config.json:fps: 2.0,min_frames: 4,max_frames: 768chat_template.jinja: emits<|vision_start|><|video_pad|><|vision_end|>foritem.type == 'video'
The Qwen3.8-27B model card advertises "Native support for image and video understanding, from STEM diagrams and documents to hour-scale videos."
mlxcel already reads video_token_id for these families (src/loading/vlm.rs:611-626, src/loading/vlm_qwen.rs), so the id plumbing is half there; nothing consumes it.
What upstream implements
mlx_vlm/models/qwen3_5/vision.py is five lines subclassing qwen3_vl's vision model, so all video behavior comes from the shared Qwen3-VL stack:
- Token merge treats image and video alike:
special_image_mask = (input_ids == image_token_index) | (input_ids == video_token_index), thenmasked_scatter.get_input_embeddingsfalls back topixel_values_videoswhenpixel_valuesis None and picksgrid_thw = image_grid_thw if image_grid_thw is not None else video_grid_thw. - 3-D MRoPE with a real temporal axis:
get_rope_indexcounts image vs video spans by inspecting the token following eachvision_start_token_id, walks them in positional order, and for a video expandst_index = mx.arange(llm_grid_t)broadcast overllm_grid_h * llm_grid_w.h/ware divided byspatial_merge_size;tis not. - Temporal patching in the tower: a 3-D conv with
kernel_size = [temporal_patch_size, patch_size, patch_size];cu_seqlensrepeats the per-frameh*wlengthgrid_thw[i,0]times. Still images are handled by duplicating along T up totemporal_patch_size, sogrid_t = 1flows through the same conv. - A real video preprocessor:
processing_qwen3_vl.pydefines aBaseVideoProcessorsubclass withmodel_input_names = ["pixel_values_videos", "video_grid_thw"],_smart_resize_video(rounds the frame count up to a multiple oftemporal_factor), bicubic per-frame resize, defaultsfps 2.0,min_frames 4,max_frames 768.
Current mlxcel gap
VlmPreparationSummary::QwenVlm(src/multimodal/vlm_runtime.rs:67-70) carries onlyimage_blocksandtotal_image_tokens. There is no video branch anywhere in the Qwen VL preparation path;grid_thwis derived from images only.compute_vlm_embeddings(src/commands/generate_vlm.rs:403-447) dispatches video to Gemma 4, Gemma 4 Unified, and Kimi-VL, then errors for everything else.cli_video_content_part_count(src/commands/generate.rs:793-801) returnsnum_videosonly forGemma4Unifiedand0otherwise, so the CLI never renders a{"type":"video"}content part for a Qwen model. That is what keeps an unexpanded<|video_pad|>out of the prompt today, and it must stay in lockstep with whatever this issue adds.- The server has no video request path for these families either.
Implementation plan
- Add a Qwen video processor: aspect-preserving
_smart_resize_videoon the 16-pixel grid, frame count rounded up to a multiple oftemporal_patch_size, bicubic per-frame resize,(pixel/255 - mean)/stdwith the checkpoint'simage_mean/image_std(both[0.5, 0.5, 0.5]), producingpixel_values_videosandvideo_grid_thw. - Honor
fps,min_frames, andmax_framesfromprocessor_config.json, with--fpsoverriding. Reject a clip that would exceedmax_frameswith a named error rather than silently truncating. - Extend the Qwen VL placeholder expansion to
video_token_id, asserting strict lockstep between declared clips, decoded clips,<|video_pad|>placeholders, projected feature rows, and final embedding positions. A cardinality mismatch must be an invalid-request error before generation; media must never be silently dropped. - Extend the MRoPE position builder to the video case: temporal index from
arange(llm_grid_t), spatial indices divided byspatial_merge_size, spans ordered by position and classified by the token aftervision_start_token_id. - Add a
VlmPreparationSummary::QwenVlmvideo count (or a new variant) so the CLI reports clips and frame slots the way the Gemma 4 and Kimi-VL paths already do. - Update
cli_video_content_part_countand the chat-template content-part builder together, so the rendered prompt and the expansion agree. - Extend the same path to the server so CLI and server produce byte-identical prompts, and ensure
usage.prompt_tokenscounts the expanded visual prefix. - Update
docs/supported-models.md.
Validation constraints
Video decoding shells out to ffmpeg (mlxcel::video::load_videos). ffmpeg is not installed on the box this investigation ran on, so no video behavior of any kind was measured here — the rejection message above is from reading the dispatch, and the whole video path is unmeasured. Whoever picks this up needs ffmpeg on PATH and should say so in the closing comment.
Suggested matrix: text-only control, single clip, clip + image in one prompt, multiple clips, a clip that trips max_frames, and B>1 concurrent requests with different expanded lengths.
Scope
- In scope:
qwen3_5(Qwen3.5-VL, Qwen3.8-27B) first, since that is the checkpoint driving this. - Adjacent:
qwen3_vl,qwen3_vl_moe,qwen2_vl,qwen2_5_vlshare the same upstream vision stack and processor, so the work should generalize. Land one family with tests, then extend. - Out of scope: audio. Qwen3.8-27B has no audio tower (confirmed against the upstream config's top-level keys and weight map); it is a vision-only VLM.
Acceptance criteria
-
mlxcel generate --video clip.mp4produces a correct description on a Qwen3.5-VL or Qwen3.8-27B checkpoint - Server video requests produce a byte-identical prompt to the CLI for the same input
- Placeholder count, projected feature rows, and
usage.prompt_tokensagree exactly; a mismatch is a pre-generation error - MRoPE temporal positions verified against the upstream reference on a fixed clip, including the
spatial_merge_sizedivision applying toh/wand nott - Mixed image + video prompt ordered correctly
-
max_framesoverflow rejected with a named error, not truncated -
cli_video_content_part_countand the expansion path covered by a test that fails if they drift apart -
docs/supported-models.mdupdated; the closing comment names what was and was not measured
Activity
inureyes commentedon Aug 15, 2026
Split out of #1163 (Qwen3.8-27B qualification), where this was found. See that issue for the full research baseline and measurement context.
feat(models): qualify Qwen3.8-27B on the qwen3_5 path (#1174)
inureyes commentedon Aug 30, 2026
Implementation is complete in PR #1510: #1510
Measured in this run: focused Rust tests for Qwen placeholder cardinality, mixed image/video prompt order, MRoPE temporal-axis handling, processor video sidecar bounds, video frame-policy rejection, Qwen3.5 token-id range checks, CLI content-part routing, server media-support detection, formatting, diff hygiene, report validation, and non-workspace clippy.
Not measured in this run: real ffmpeg-backed video decoding, real Qwen3.5/Qwen3.8 checkpoint generation, and CLI/server byte-identical prompt comparison on a concrete video fixture.
ffmpegandffprobeare not installed, and this worktree has nomodels/ormodels/mlxcheckpoint tree. GPU visibility was checked outside the sandbox and reports NVIDIA GB10 with driver 580.173.02 and CUDA 13.0, but no real model run was possible without the checkpoint and ffmpeg tools.Technical reports were added to the PR branch:
TECHNICAL_REPORTS/1510-qwen-vl-video-input-20260830.en.mdandTECHNICAL_REPORTS/1510-qwen-vl-video-input-20260830.ko.md.feat(models): add Qwen-VL video input (lablup#1510)
feat(server): send sampled video frames as ordered images (#1759)