Skip to content

fix(video): align the CLI and server video-frames fallback - #1777

Merged
inureyes merged 4 commits into
mainfrom
fix/issue-1766-video-fallback-alignment
Sep 10, 2026
Merged

inureyes merged 4 commits into
mainfrom
fix/issue-1766-video-fallback-alignment

Conversation

@inureyes

@inureyes inureyes commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes the five follow-ups #1759 left on the video-frames fallback. The CLI and the server now render the same prompt for the same clips, and the server's request path neither blocks nor copies the body for it.

What changed

  • Per-clip lead sentence (CLI). video_frames_lead_text moved to multimodal::video and both fronts call it. CliVideoFrames holds one entry per clip. A new CliPromptMedia layout renders each clip's sentence immediately ahead of that clip's frames, matching apply_video_frame_expansion. The issue's plan to prepend the sentences to -p would not have worked, because the CLI renders every image before the text item. -p itself is no longer rewritten.
  • One flattening for templates without image items. Both fronts now go through flatten_template_text (server::chat_template). Before this, the CLI joined the parts with \n\n and the server with no separator, so families such as internvl3, deepseek-vl2, fastvlm, molmo and dots.ocr got different prompts on the two fronts. The CLI's no-template path now uses the same join.
  • Private frames (CLI). A new PrivateTempDir (std only) creates a per-run 0700 directory whose prefix and file names must be plain names. It writes each PNG with create_new at 0600 and removes the directory on drop.
  • No blocking work on a worker (server). At startup the server resolves MLXCEL_VIDEO_DIR_ALLOWLIST into AppState, warms the ffmpeg probe, and warns about writable allowlist directories. The router does the same on each model load. extract_chat_video_paths checks a non-allocating has_video_urls() and returns early for bodies without a clip. Operator-visible change: the fallback reads the allowlist once, so a directory created later needs a restart (documented).
  • No request clone. render_chat_prompt takes the request by value and expands it in place.
  • Cancellable decode. expand_request_video_parts creates a CancellationToken only when the request actually has a clip to expand, and holds its drop guard in the handler future. The token is checked before the clip loop, before each clip, and when the blocking decode starts. A correction to the issue: the guard fires only when the future is dropped, which already stops the loop, so the check that matters in production is at decode start, where a queued decode now never begins. Cancelled maps to 499 as defensive code; no live route can receive it.

Validation

  • Workspace gate on 0dad212: 121 of 122 binaries ran clean (9,297 passed). The mlxcel-core lib binary aborted mid-run with [METAL] Command buffer execution failed: Discarded (victim of GPU error/recovery) (kIOGPUCommandBufferCallbackErrorInnocentVictim), the error macOS reports when another process's GPU fault forces a recovery. Re-run alone, that binary passes 1,731/1,731. This PR does not touch mlxcel-core. On c5204bf the gate passed in one run: 123 binaries, 11,024 passed. Workspace clippy with -D warnings, fmt and the make verify-* contract checks are clean.
  • Revert arms: each new test fails by name with its fix reverted, including the new parity tests against the old \n\n join and the prefix check.
  • Real run with gemma-3-4b-it-4bit, two synthetic clips (the red square moves left to right, the blue one top to bottom; first and last frames checked by eye) and --video-max-frames 4:
    • After this PR: the CLI Prompt tokens: and the server's usage.prompt_tokens are both 2,129. The CLI prints one frame line per clip. The server returns HTTP 200, and /apply-template shows two lead sentences and eight image markers.
    • A router-mode server (--models-dir over the store) gives the same 2,129 for the same body.
    • Before this PR the CLI read 2,115 against the server's 2,129, the cost of one lead sentence.
  • /v1/chat/completions/input_tokens returns 57 for this body, before and after this PR. It counts the rendered template text without expanding image soft tokens. That is a separate property of the route, not a regression here, and it is noted as a follow-up rather than changed.

Not changed (follow-ups)

  • The server's native-video write_video_temp_file still writes a 0644 file that a disconnect can leave behind.
  • The native-video path still reads the allowlist per request (off the request thread).
  • input_tokens does not count image soft tokens.
  • A template that takes video or audio items but not image still receives the content list on the server and a flattened string on the CLI.

Closes #1766

The video-frames fallback from #1322 left five follow-ups on its two fronts.

The CLI announced every --video clip with one sentence naming their summed frame count, placed after all frames, while the server gives each clip its own sentence ahead of its own frames. The sentence now lives in multimodal::video, both fronts call it, and the CLI renders each clip's sentence before that clip's frames through a new CliPromptMedia layout. Text-only renders still carry the sentences ahead of the question.

CLI frame PNGs went into the shared temp directory at the default mode. They are now written 0600 into a per-run 0700 directory (PrivateTempDir, std only) that is removed when the run ends.

The server resolved MLXCEL_VIDEO_DIR_ALLOWLIST and spawned the ffmpeg probe on a Tokio worker. Startup now resolves the allowlist into AppState and warms the probe for video-capable models, in the single-model server and per router model load. The native-video path skips the allowlist entirely for bodies without a clip and otherwise resolves it on the blocking pool.

render_chat_prompt takes the request by value and expands it in place instead of cloning the whole body.

The three wiring routes pass a CancellationToken whose drop guard lives in the handler future. It is checked before the clip loop, before each clip is resolved, and at the start of the blocking decode, and a cancelled expansion answers 499 instead of 400.

Validated with the affected lib scopes, the generate bin scope, the ffmpeg-gated fallback tests, clippy and fmt.

Closes #1766
@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:low Low priority area:inference Generation, sampling, decoding (incl. speculative, DRY) area:cli Command-line interface / CLI flags modeltype:vlm Vision-language model status:review Under review labels Sep 10, 2026
The test comment claimed the pre-#1766 server decoded every remaining clip after a disconnect. Dropping the handler future already stopped that loop; what the check guards is a caller that keeps polling after the token fires, which the comment now says.
For a template without image content items the CLI joined two clips' lead sentences and the question with blank lines, while the server's typed-message render concatenates text parts with no separator, so InternVL3, DeepSeek-VL2, FastVLM, Molmo and dots.ocr prefilled different prompts for the same clips. The flattening now lives in chat_template::flatten_template_text, the server's typed and raw message builders call it, and the CLI flattens its text-only renders through it. A lib test pins the prepare_chat_request render and a bin test pins the resolve_cli_prompt render to the same string; the bin test fails with the old join.

Review follow-ups in the same area: ChatCompletionRequest::has_video_urls replaces a video_urls().is_empty() check that cloned inline data:video payloads; the route helper returns before allocating a cancellation token when the request has no clip or the checkpoint has no fallback; PrivateTempDir::create validates its prefix as a plain name, like write_file does; the writable-allowlist warning runs from resolve_video_request_inputs, so router-loaded models warn too; the Cancelled and 499 docs now say the path is defensive; supported-models.md states the parity for bodies that list clips before the question.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cli Command-line interface / CLI flags area:inference Generation, sampling, decoding (incl. speculative, DRY) modeltype:vlm Vision-language model priority:low Low priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

1 participant