Repository navigation
fix(video): align the CLI and server video-frames fallback (per-clip lead sentence, temp file mode, blocking probes, request cloning, cancellable decode) #1766
Description
Summary
PR #1759 (issue #1322) landed the video-frames fallback: a clip is decoded, evenly subsampled to --video-max-frames, PNG-encoded, and spliced back into the request as ordered image_url parts for a checkpoint that has a vision tower and no native temporal path. Its review and security passes reported five MEDIUM/LOW items that were deliberately left out of that PR as pre-existing or out of that issue's scope; they are listed in the "Reported and not fixed" paragraph of the #1759 body and in TECHNICAL_REPORTS/1759-video-frames-fallback-20260910.en.md. All five sit on src/multimodal/video.rs, src/server/chat_request.rs, src/server/media.rs and src/commands/generate.rs, so they are one issue and one PR. Every citation below was re-verified against e3ca8afc.
Current behavior
-
One lead sentence for all clips on the CLI, one per clip on the server.
expand_cli_videos_to_framessums the frame count across every--videoflag atsrc/commands/generate.rs:942, builds a singlelead_textfrom that sum atsrc/commands/generate.rs:957-964, andrun_generate_onceprepends it once atsrc/commands/generate.rs:2529. The server does the opposite:apply_video_frame_expansionpushes onevideo_frames_lead_text(frames.len())per clip at that clip's own position (src/server/chat_request.rs:568-578, sentence atsrc/server/chat_request.rs:367). Two clips through the CLI therefore reach the model as one undivided run of frames announced with a summed count, and the model has no way to tell where the first clip ends and the second begins. The same two clips through the server arrive correctly separated, so the two fronts answer the same question differently. -
CLI frame PNGs are world-readable in a shared directory.
expand_cli_videos_to_frameswrites every frame intostd::env::temp_dir()(src/commands/generate.rs:918) with a plainstd::fs::writeatsrc/commands/generate.rs:944-953, which creates mode 0644 minus umask. This matches the pre-existingwrite_video_temp_fileatsrc/server/media.rs:996-1015and was reported as inherited rather than introduced. Any local user can read the decoded frames of a private clip for the life of the run. -
Two blocking calls run on a Tokio worker.
expand_video_parts_to_framescallsvideo_dir_allowlist_from_env()atsrc/server/chat_request.rs:392andffmpeg_available()atsrc/server/chat_request.rs:457, both from async context with nospawn_blocking.ffmpeg_available()(src/multimodal/video.rs:889-909) spawnsffmpeg -versionandffprobe -version; it is memoized behind aOnceLock, so only the first caller pays, but that first caller is a Tokio worker on the first video request of the process.video_dir_allowlist_from_env()(src/server/media.rs:585-608) is not memoized at all: it re-reads the env var andstd::fs::canonicalizes each entry on every request, atsrc/server/chat_request.rs:392and again atsrc/server/media.rs:544on the native-video path. -
The prompt-inspection route clones the whole request.
render_chat_prompttakes&ChatCompletionRequest(src/server/routes/prompt_inspection.rs:95-98) and clones it wholesale atsrc/server/routes/prompt_inspection.rs:119whenever the body carries a clip, duplicating every caller-supplied base64 image payload and every tool definition alongside the messages it is about to rewrite. -
The decode is not cancellable. The ffmpeg decode plus PNG encode runs in
tokio::task::spawn_blockingatsrc/server/chat_request.rs:484-495. A droppedJoinHandledoes not cancel a blocking task, so when the client disconnects the handler future is dropped while the decode keeps running to--video-max-frames, once per clip, for a response nobody will read.
Expected behavior
- The CLI splices one lead sentence per clip, naming that clip's own frame count, immediately ahead of that clip's frames, matching the server.
- CLI frame PNGs are created mode 0600 inside a per-run directory created mode 0700, and both the files and the directory are removed when the run ends.
- No request-path
awaitpoint is blocked by a process spawn or a filesystem canonicalization for the video fallback. - Rendering a prompt for a body carrying a clip does not duplicate the caller's image payloads.
- A client disconnect stops the fallback before the next clip is decoded and before a queued decode starts.
Implementation plan
-
Per-clip lead sentence on the CLI. Change
CliVideoFrames(src/commands/generate.rs:870-878) solead_text: Stringbecomes an ordered per-clip structure: carryVec<(String, Vec<PathBuf>)>of (sentence, that clip's frame paths) instead of one sentence plus one flatframe_paths. In the loop atsrc/commands/generate.rs:922-955build oneformat!("Here is a video as a sequence of {} frames in chronological order.", encoded.len())per clip fromencoded.len(), not from the runningtotal_frames. At the call site (src/commands/generate.rs:2521-2534) prepend the sentences in clip order ahead ofuser_prompt, one per clip, and extendargs.generation.imagein the same order, so the Nth sentence still precedes the Nth clip's frames onceload_cli_promptemits one image content part per frame. Reuse the server's wording verbatim; better still, exportvideo_frames_lead_text(src/server/chat_request.rs:367) and call it from both fronts so the sentence cannot drift, adding the// Used by:comment the repository requires for a shared function. -
Private directory, 0600 frames. Create
temp_dir().join(format!("mlxcel-video-frames-{}", Uuid::new_v4()))once perexpand_cli_videos_to_framescall withstd::fs::DirBuilder::new().mode(0o700)under#[cfg(unix)]and a plaincreate_direlsewhere, replacing the barestd::env::temp_dir()atsrc/commands/generate.rs:918. Write each PNG throughOpenOptions::new().write(true).create_new(true).mode(0o600)(std::os::unix::fs::OpenOptionsExt) under#[cfg(unix)], falling back tostd::fs::writeelsewhere, in place of the write atsrc/commands/generate.rs:946.TempFile(src/multimodal/video.rs:491-519) only unlinks a file, so add a sibling directory guard beside it that callsremove_dir_allon drop, and hold it inCliVideoFrames::_guardsso it outlives the vision-tower read exactly as the file guards do. Usestd, not thetempfilecrate:tempfile = "3"is a dev-dependency only (Cargo.toml:343-345) and promoting it to[dependencies]for this is not worth the release surface. Leavewrite_video_temp_file(src/server/media.rs:996-1015) alone: it is the server's native-video path and a separate change. -
Move the blocking work to startup. Call
ffmpeg_available()once during server startup so theOnceLockatsrc/multimodal/video.rs:891is already warm when the first request arrives, and keep the request-path call atsrc/server/chat_request.rs:457as the cheap memoized read it then is. For the allowlist, add a resolvedArc<Vec<PathBuf>>field onAppStatenext tomedia_support(src/server/state.rs:466, builder atsrc/server/state.rs:734, defaults atsrc/server/state.rs:647and:706), populate it during startup wherewarn_on_insecure_video_allowlistalready reads the env var (src/server/startup.rs:1089), and have the three routes pass it to the existingexpand_video_parts_to_frames_with_allowlist(src/server/chat_request.rs:403) andextract_chat_video_paths_with_allowlist(src/server/media.rs:547). Do not put aOnceLockinsidevideo_dir_allowlist_from_env()itself: the_with_allowlistvariants exist becauseMLXCEL_VIDEO_DIR_ALLOWLISTis process-global and the tests inject a controlled allowlist rather than hold the crate env lock across awaits, and memoizing the getter would break them. -
Take the request by value. All three callers of
render_chat_promptalready own the value they pass:src/server/routes/prompt_inspection.rs:204and:234pass a locally parsedChatCompletionRequest, and:273passestranslated.chat_request, whichtranslatedowns. Change the parameter tomut request: ChatCompletionRequest, expand in place, and delete the clone and theexpandedlocal atsrc/server/routes/prompt_inspection.rs:117-130. Do not introduce aCowor a borrowed view type; the ownership already exists at every call site. -
Thread a cancellation token. Use
tokio_util::sync::CancellationToken(tokio-util is already a direct dependency atCargo.toml:229, andsrc/distributed/heartbeat.rs:48already uses it), not theArc<AtomicBool>alias atsrc/server/streaming.rs:139, which belongs to the SSE sender and does not exist yet at this seam: the expansion runs atsrc/server/routes/chat.rs:368, before the SSE channel is created. In each wiring route (src/server/routes/chat.rs:368,src/server/routes/responses.rs:111,src/server/routes/prompt_inspection.rs:120) create a token, hold itsdrop_guard()in the handler future so a client disconnect cancels it when the future is dropped, and pass the token intoexpand_video_parts_to_frames*. Check it in three places: once before the clip loop, once per clip immediately beforeresolve_video_url, and at entry to thespawn_blockingclosure (src/server/chat_request.rs:484-495), which matters because that closure can sit queued behind other blocking work. Return a distinctErrvariant the routes drop rather than render as a 400. Out of scope, and stated so an implementer does not attempt it: killing an ffmpeg child that is already mid-decode.extract_frames_single_pass(src/multimodal/video.rs:1729) has three callers in that file (:1279,:1442,:1636), so threading a kill handle through it is a shared-function change belonging to its own issue. What this buys is a bound: at most one clip's decode is wasted instead of the whole body's.
Validation
Existing scopes, all of which this change touches:
cargo test --profile test-fast --features metal,accelerate --lib multimodal::video
cargo test --profile test-fast --features metal,accelerate --lib server::chat_request
cargo test --profile test-fast --features metal,accelerate --lib server::startup
cargo test --profile test-fast --features metal,accelerate --lib server::media
cargo test --profile test-fast --features metal,accelerate --bin mlxcel commands::generate
commands::generate_tests lives in the binary crate, not the lib, so --lib commands::generate matches zero tests and the --bin mlxcel scope is the correct one (#1759 report section 5.6).
New tests required:
- Item 1, in
src/commands/generate_tests.rs, alongsidecli_video_fallback_appends_frame_images_and_clears_video: two clips produce two lead sentences, each naming its own clip's frame count rather than the sum, and each sentence precedes its own clip's frames in the rendered prompt. - Item 2, in
src/commands/generate_tests.rsunder#[cfg(unix)]: the per-run frame directory is mode 0700 and every PNG in it is mode 0600, and both the files and the directory are gone once the guards drop. - Item 5, in
src/server/chat_request_tests.rs: a token cancelled before the call returns the cancellation error without invoking ffmpeg, and a token cancelled after the first clip stops before the second is resolved.
Real run on models/gemma-3-4b-it-4bit with ffmpeg at /opt/homebrew/bin, using two short synthetic clips. Generate them with overlay, which produces visible motion:
ffmpeg -f lavfi -i "color=c=white:s=448x448:d=4:r=25" -f lavfi -i "color=c=red:s=128x128:d=4:r=25" -filter_complex "[0:v][1:v]overlay=x='(W-w)*t/4':y=(H-h)/2" -c:v libx264 -pix_fmt yuv420p clip_a.mp4
ffmpeg -f lavfi -i "color=c=white:s=448x448:d=4:r=25" -f lavfi -i "color=c=blue:s=128x128:d=4:r=25" -filter_complex "[0:v][1:v]overlay=x=(W-w)/2:y='(H-h)*t/4'" -c:v libx264 -pix_fmt yuv420p clip_b.mp4
Do not build the fixture with drawbox and a t-dependent x: it renders four seconds of blank white, and the run then looks like a successful validation because the log line, the frame count and the token growth are all correct while every model simply describes a blank frame (#1759 report section 5). Check the first and last frame by eye before trusting any number.
Then confirm on both fronts, at --video-max-frames 4, greedy decoding, the same user text:
- CLI:
./target/release/mlxcel generate -m models/gemma-3-4b-it-4bit --video clip_a.mp4 --video clip_b.mp4 --video-max-frames 4 --profile -p "..."emits two lead sentences, each naming 4 frames, and--profilereports thePrompt tokens:line (src/lib/mlxcel-core/src/generate.rs:1331). - Server:
./target/release/mlxcel-server -m models/gemma-3-4b-it-4bit --video-max-frames 4withMLXCEL_VIDEO_DIR_ALLOWLISTset to the clips' directory, posting both clips in one/v1/chat/completionsbody, logs the fallback line twice and returns HTTP 200. - The CLI's
Prompt tokens:and the server'susage.prompt_tokensare identical for that body, which is the check that the two fronts now splice the same sentences at the same positions. They differ today, by the token cost of one lead sentence, because the CLI emits one sentence where the server emits two. POST /v1/chat/completions/input_tokenson the same body returns that same count, which exercises item 4's now-uncloned call path.
Acceptance criteria
- The CLI emits one lead sentence per
--videoclip, naming that clip's own frame count, spliced ahead of that clip's frames. - CLI frame PNGs are mode 0600 inside a mode 0700 per-run directory, and both are removed when the run ends.
-
ffmpeg_available()and the video directory allowlist are resolved at startup; no request-path call spawns a process or canonicalizes a path for the fallback. -
render_chat_prompttakes the request by value; the whole-request clone atsrc/server/routes/prompt_inspection.rs:119is gone. - A cancellation token reaches
expand_video_parts_to_frames*from all three wiring routes and is checked before the clip loop, per clip, and atspawn_blockingentry. - New tests for items 1, 2 and 5 exist and pass, and the existing
multimodal::video,server::chat_request,server::startup,server::mediaandcommands::generatescopes stay green. - The real run above shows two lead sentences on both fronts and identical prompt-token counts between the CLI and the server for the same two clips.
-
cargo test --workspace --profile test-fast --features metal,acceleratepasses. -
cargo clippy --workspace --all-targets -- -D warningsandcargo fmt --checkare clean.
Activity
test: state what the per-clip cancellation check guards
fix(video): align the CLI and server video-frames fallback (lablup#1777)