Skip to content

[Model] Cap the decoded size of request images and videos - #510

Merged
merceod merged 13 commits into
mstar-project:mainfrom
arun-elr:hardening/media-decode-limits
Oct 19, 2026
Merged

merceod merged 13 commits into
mstar-project:mainfrom
arun-elr:hardening/media-decode-limits

Conversation

@arun-elr

@arun-elr arun-elr commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Request images and videos are decoded in the API server's preprocess thread, and on main nothing bounds what they decode to: a 236 KB PNG can declare 9000x9000, and a still clip decodes to many times its size. Any client, with no credentials (#506), can send such media, or many files in one request (#590), and stall preprocessing and output delivery for every other user, or run the API server out of memory, which fails every in-flight request. This PR checks what a request's media will decode to before decoding it and answers an oversized request with a 400 that names the limit.

  • Images. A header check against MSTAR_MAX_IMAGE_PIXELS (media_limits.py, image loaders); PNG, JPEG, WebP and GIF only, where BMP or TIFF was a 500; FLUX.2 klein, which decodes with Pillow, still reads BMP and TIFF.
  • Videos. Pixels counted per decoded frame, or from the header before a sampled decode (MSTAR_MAX_VIDEO_PIXELS).
  • Per request. The caps hold for a request's files together, at most 16 files (MSTAR_MAX_MEDIA_FILES) in _process_input (part of [Data worker][Security] Media limits apply per file, so many files in one request multiply them #590).
  • Qwen3-Omni. A bounded whole-clip reader (qwen3_omni/video_reader.py); the original decodes everything and fails on torchvision 0.27. A clip whose header gives no frame count (an MKV or WebM without one) is decoded by it too, not by the sampled readers, which would decode it uncounted.
  • Startup. Knobs checked in check_media_limits() and documented; Pillow's bomb check, which masked caps over 179M pixels, is lifted to the image cap.

How was it tested?

On 5fb1685 (head): ruff check . clean. python -m pytest -n 4 @test/cpu-core.txt (GPU hidden, CI's install) on 4f15aab9f, the same commits on main 8e9c61f (the two main commits since do not touch media loading): 2273 passed, 363 skipped, 3 failed (test_wan_vae_decode.py 2; both fail on main 56c6fbd here; test_eval_framework.py's SIGTERM case timed out under host load and passes alone, here and on main). python -m pytest test/modular/test_media_decode_limits.py: 42 passed (the 16-bit PNG cases fail on efc39be; the FLUX.2 klein BMP and TIFF cases and the header-less MKV case fail on 2130af3). Served on one B200 (Qwen3.5-0.8B images, V-JEPA 2 videos): ordinary inputs 200, inputs over a lowered limit 400, the server keeps serving; on 9687e9a, one file per request, which the later changes count the same way.

Real-user checks (head 4ec834f00): 39 threshold cases through the data worker's real loaders on CPU (images at, under and over a lowered cap, alone and summed over a request; 16 and 17 files; videos at 100 and 101 minimum frames through the base, Cosmos3 torchcodec and PyAV, V-JEPA 2 and Qwen3-Omni readers, with 2x2, 16x16 and 5,000 tiny frames) all behave as designed and no message names a path; served on one H200 through Wan 2.2 TI2V with MSTAR_MAX_IMAGE_PIXELS=1000000, 1000x1000 decodes and 1000x1001 is a 400, a request 1,000 pixels over the summed cap or with 17 files is a 400, and BMP, TIFF, empty files and animated GIF or WebP get a 400 or decode where main answers 500.

Before and after. Before: a request image or video is decoded in full (every GIF frame, every video frame), then copied to float32, and either reaches the model or exhausts the API server's memory. After: an image's header is read with Pillow and compared with MSTAR_MAX_IMAGE_PIXELS, then a GIF is read with Pillow as its first frame and other formats with torchvision; a video is checked against MSTAR_MAX_VIDEO_PIXELS from the header (sampled loaders) or frame by frame as it is decoded (full-decode loaders). Over either limit the client gets a 400 that names the limit and never the server-side path.

Not tested, and not here

Not tested: a served refusal on Cosmos3 or Qwen3-Omni, and the Waypoint and FLUX.2 klein loaders served (CPU tests only).

Not here: a decoded total for audio, and per-model file counts checked before decoding, stay in #590.

Depends on

Depends on: none.

Design
  • Why the header: the decoders allocate what the header declares, so the header can't understate an image's decode. A full-decode video loader counts frames as it decodes them instead, because a container header can understate the frame count (an AVI whose header claims 4 frames can hold 300).
  • Per request: the data worker loads each file inside RequestMediaBudget.file(), and every image or video check also charges the request. A file is charged the largest single check made for it, so Qwen3-Omni's header estimate and the whole-clip fallback that may follow are not added together. Outside the data worker (direct loader calls) each file is checked alone against the same caps.
  • Behavior changes: an animated GIF or WebP is read as its first frame (the base loader returned every GIF frame as a 4-D tensor, which no image model consumes). BMP, TIFF and other formats get a 400 instead of a 500 from the torchvision loaders; FLUX.2 klein reads BMP and TIFF with Pillow, as on main. A 16-bit PNG is read as its high byte (uint8, as Pillow reads a 16-bit RGB PNG): torchvision returns uint16, which the base loader's divide by 255 turned into values up to 257. A request whose images or videos are over the cap together, or that attaches more than 16 files, gets a 400.
  • 1080p ceilings at the default: Cosmos3 decodes every frame, so it refuses 1080p30 clips over about 17 s; Qwen3-Omni decodes 2 frames a second, so over about 259 s. Both are in the MSTAR_MAX_VIDEO_PIXELS row.
  • qwen-omni-utils' own fallback reader is already broken on main: it calls torchvision.io.read_video, which torchvision 0.27.1 removed. The replacement here is the only whole-clip reader that works.
  • Error text: a file Pillow can't read gets a fixed message; the decoder's own message can name the server-side path. A refusal is a ValueError, which _fail_request already maps to a 400 error chunk.
  • Known limit, not changed here: a stream whose resolution grows after the header is counted by the full-decode loaders but not by the two sampled ones.
Video default: why 2^30

Only the Cosmos3 family reaches the full-decode Model.load_video (Cosmos3Model.load_video calls it); V-JEPA 2 and Qwen3-Omni sample before decoding, and Qwen3.5 refuses video. The repo's own video-to-text eval, benchmark.runner --dataset video_mme, sends Video-MME's short clips: the 34 clips of a default 100-request run are 640x360, 31 to 118 s, 170M to 788M decoded pixels. 31 of the 34 exceed 2^28 and none exceed 2^30.

Cosmos3-Edge on one H200, measured before the request total and file count were added (a single-clip request is counted the same way since), /v1/chat/completions with one clip and a one-sentence question, one request at a time:

Clip (decoded pixels / 2^28) main 2^28 2^30 (this PR) API server peak RSS
XWfqBKeC0g8, 31 s (0.64) 200 200 200 5.5 GB
fFjv93ACGo8, 74 s (1.91) 200 400 200 16.5 GB
TPS22HRRY1k, 114 s (2.93) 200 400 200 28.5 GB

At 2^30 the replies match main's word for word, at the same peak RSS. Sampling before decoding in Cosmos3, as V-JEPA 2 does, would bound this far lower and could follow separately.

Correctness
  • Qwen3-Omni's sampled path: with torchcodec, the PR adds a header read (probe_video, seek_mode="approximate" so it does not scan the file) and a pixel count before the same fetch_video call. The review found the frames torch.equal to main's on 7 clips, and two main servers differing from each other on the same prompts as main vs this PR, so the reply differences in the earlier live test are cross-process variation main already has.
  • Qwen3-Omni's whole-clip fallback: on CPU, against qwen-omni-utils 0.0.9's own reader (torchvision 0.24.1), it returned identical frames for two Video-MME clips, both alone and through fetch_video, and refused the AVI above at a 10-frame budget. With qwen-omni-utils 0.0.9 and decord 0.6.0, a 30-frame MKV whose header has no frame count now goes to this reader: refused at a 3-frame budget, and at the default it loads with the same shape as before, charged for all 30 frames (it was charged 0).
Served on a B200

On 9687e9a, one B200 (torch 2.12.1, CUDA 13.0), through the normal servers, one file per request. To show a refusal without a special file, the second run of each model lowers the limit so that an ordinary larger input is over it.

Model, route Limit Input Result
Qwen3.5-0.8B, /v1/chat/completions default 1219x506 PNG 200, text
default 1600x1200 PNG 200, text
MSTAR_MAX_IMAGE_PIXELS=1000000 1219x506 PNG 200, text
1600x1200 PNG 400 image is 1600x1200 (1920000 pixels); the limit is 1000000 pixels
1219x506 PNG again 200, text
V-JEPA 2, /generate default 80 frames, 256x256 200, 32 MiB of embeddings
default 80 frames, 640x480 200, 32 MiB of embeddings
MSTAR_MAX_VIDEO_PIXELS=8000000 80 frames, 256x256 200
80 frames, 640x480 400 video decodes to more than 8000000 pixels; send a shorter or smaller clip (the limit is MSTAR_MAX_VIDEO_PIXELS)
80 frames, 256x256 again 200

The server keeps serving after a refusal. Streaming requests get the same 400 as their final in-band error chunk.

Tests in detail

test/modular/test_media_decode_limits.py, in test/cpu-core.txt:

  • an oversized header is refused before the decoder runs (a decoder stub fails the test if reached), for the base, Waypoint and FLUX.2 loaders; BMP and TIFF are a 400 on the torchvision loaders, while FLUX.2 klein reads them and checks their header; an animated WebP is read as its first frame without torchvision;
  • MSTAR_MAX_IMAGE_PIXELS=400000000 admits a 200M-pixel header after the startup check (Pillow alone refuses it), and a lowered value leaves Pillow's limit alone;
  • eight 9000x9000 headers in one request are refused at the second, which is never decoded, and come back as a 400; images exactly at the cap together all load; more files than MSTAR_MAX_MEDIA_FILES are refused before any is loaded;
  • a video's header estimate and its decode are charged once, and the next file adds to it; outside a request each file is checked alone;
  • normal PNGs and GIFs load with the expected shape; GIF frames larger than the logical screen never reach torchvision, and an oversized first frame is counted;
  • the base and Cosmos3 PyAV loaders stop at the budget and still return a normal 10-frame clip; V-JEPA 2 and Qwen3-Omni refuse before decoding, a Qwen3-Omni clip whose header gives no frame count is counted frame by frame instead of by a sampled reader, and V-JEPA 2 decodes one frame to learn the size when the header has none; a frame under 64x64 counts as 64x64; the whole-clip reader stops as it decodes and samples like the original; probe_video opens torchcodec with seek_mode="approximate";
  • invalid env values are an error, and stop the data worker at startup.

The image cases use header-only files, so nothing large is allocated.

Checklist

  • ruff check . passes
  • Added or updated tests / docs where relevant

AI assistance: written with AI coding agent

@arun-elr arun-elr changed the title model: cap the decoded size of request images and videos [Model] Cap the decoded size of request images and videos Oct 16, 2026
@arun-elr

Copy link
Copy Markdown
Contributor Author

/review

Comment thread docs/environment_variables.rst Outdated
Comment on lines +201 to +211
* - ``MSTAR_MAX_IMAGE_PIXELS``
- ``89478485``
- Pixels one request image may decode to, checked from the header
before decoding; larger images get a 400. A GIF is read as its first
frame. Applies behind either frontend. Pillow refuses images over
twice its own limit (about 179M pixels) whatever this is set to.
* - ``MSTAR_MAX_VIDEO_PIXELS``
- ``268435456``
- Pixels one request video may decode to, summed over the frames the
model's loader decodes (about 10 s of 720p30 for loaders that keep
every frame); larger videos get a 400. Applies behind either frontend.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The new rows don't name the function that reads each variable.

AGENTS.md invariant 9 asks each new MSTAR_* row to name its reader. Here that is mstar.model.media_limits.max_image_pixels and max_video_pixels. The other rows do this with :func:. These two also sit under "Serving (Python frontend) — Read by mstar-serve / mstar.api_server.entrypoint". In fact the model loaders read them in the data worker's preprocess thread, and the rows themselves say they apply behind either frontend.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: both rows now sit in their own "Request media" section, which says they are read by the model loaders in the data worker's preprocess thread, and each row names its reader (mstar.model.media_limits.max_image_pixels / max_video_pixels).

Comment on lines +18 to +25
def _positive_int_env(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None or raw.strip() == "":
return default
value = int(raw)
if value <= 0:
raise ValueError(f"{name} must be a positive integer; got {raw!r}")
return value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] A misconfigured limit comes back as a client 400 on every media request, and the server still starts.

max_image_pixels() / max_video_pixels() are read per request, inside check_image_size and VideoBudget(). Say MSTAR_MAX_VIDEO_PIXELS=lots or 0. The server starts fine. Then every video request raises ValueError in the preprocess thread, and _fail_request (data_worker.py:688) turns that into a 400. The client is told its request was bad, and the error text is a server env var message. Image requests behave the same way for MSTAR_MAX_IMAGE_PIXELS. Reading and validating both values once, at import or at data-worker startup, would make a bad deploy fail at boot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: PreprocessWorker now reads and validates both values when it is built (check_media_limits), so an invalid value such as lots or 0 stops the server at startup behind either frontend. The error names the variable, and a test covers it.

@github-actions

Copy link
Copy Markdown
Contributor

Automated review

The PR adds mstar/model/media_limits.py. Before decoding, it checks a request image's size from its header against MSTAR_MAX_IMAGE_PIXELS, and it counts decoded video pixels against MSTAR_MAX_VIDEO_PIXELS. The checks are wired into the base load_image/load_video and into the Waypoint, FLUX.2 klein, Cosmos3 (PyAV fallback), V-JEPA 2 and Qwen3-Omni loaders. Animated GIFs are now read as their first frame. This matches the description, and a grep of mstar/model/ finds no other request-media loader the PR leaves out.

Findings: 2 inline (2 × P2).

The description is accurate and says which tests were run and what they returned. The tightened default for the base full-decode video loader, which Cosmos3-Edge and any other model relying on Model.load_video will hit, is raised openly as a question, so it isn't reported as a finding.

Questions

  • Which deployed models use the base Model.load_video (full decode, about 10 s of 720p30 at the default)? Would any of their current benchmark or eval clips exceed 2^28 pixels? If so, this PR turns requests that work today into 400s until the knob is raised.

Advisory, not a merge gate. Rules: AGENTS.md. Reply to discuss.

@arun-elr

arun-elr commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Which deployed models use the base Model.load_video (full decode, about 10 s of 720p30 at the default)? Would any of their current benchmark or eval clips exceed 2^28 pixels?

Only the Cosmos3 family (Cosmos3, Cosmos3-Edge and their variants), through Cosmos3Model.load_video. V-JEPA 2 and Qwen3-Omni sample before decoding, and Qwen3.5 refuses video. And yes, some of their clips would. The repo's video-to-text eval (benchmark.runner --dataset video_mme) sends Video-MME's short clips: 640x360, up to 2 minutes. 31 of the 34 clips in a default 100-request run exceed 2^28 decoded pixels (up to 2.93x). On Cosmos3-Edge (one H200), main answered all three clips sent with a 200, and 2^28 turned the 74 s and 114 s clips into 400s. So the default was too low, and this push changes it:

  • Video default is now 2^30, which fits every short Video-MME clip. At 2^30, Cosmos3-Edge answers all three clips with the same text as main, at the same peak API server memory (28.5 GB for the 114 s clip).
  • Qwen3-Omni's whole-clip fallback is now bounded. The higher default would otherwise let longer clips reach qwen-omni-utils' torchvision fallback, which decodes every frame. That reader is replaced with one that returns the same frames (identical output against the original on two Video-MME clips) and counts each frame as it decodes, so a header that understates the clip can't get past it.
  • The two inline points: an invalid value of either variable now stops the server at startup, and the docs rows moved to their own "Request media" section, which names the functions that read them.

The table is in the description under "Video default: why 2^30". Sampling before decoding in Cosmos3 would bound this much lower and could follow separately.

@NSagan271

Copy link
Copy Markdown
Collaborator

🤖 Automated live test, AI-generated (Claude Code). Not checked line by line by a human. Numbers come from one allocation, so treat small deltas as provisional.

Live test: [Model] Cap the decoded size of request images and videos

The image caps work as described (400 naming the limit, decompression bomb refused in 0.1 s where main spent 20 s and 4.3 GB of API server RSS, GIFs fixed, bad env value stops startup). One open question: Qwen3-Omni video replies moved on the PR and not on main.

1x H200, qwen3_5_0.8b (stock config), and 2x H200 qwen3omni_2gpu.yaml · PR head 9687e9aa vs main merge-base d5168ce3

Claims

Claim Result
An image over MSTAR_MAX_IMAGE_PIXELS gets a 400 naming the limit ✅ MSTAR_MAX_IMAGE_PIXELS=1000000, 1600x1200 PNG via /v1/chat/completions: PR 400 image is 1600x1200 (1920000 pixels); the limit is 1000000 pixels in 0.1 s; main 200. 800x600 is 200 on both
A small file that decodes huge is refused before decoding ✅ 20,000x20,000 flat PNG (438 KB), default limit: PR 400 image is too large to decode; the limit is 89478485 pixels in 0.1 s, API server RSS unchanged (1.1 GB). Main decoded it: 200 after 19.8 s, API server RSS 1.1 to 5.4 GB peak
Animated GIF is read as its first frame ✅ 3-frame GIF: PR 200 with a normal reply; main 500 (TorchRuntimeError in the worker: the 4-D tensor reaches the model), on both limit settings
An invalid env value stops the server at startup ✅ MSTAR_MAX_IMAGE_PIXELS=abc: PR refuses to start, ValueError: MSTAR_MAX_IMAGE_PIXELS must be a positive integer; got 'abc'
Values unchanged for normal inputs ⚠️ Qwen3-Omni greedy chat, 3 image + 3 video prompts (800x600 PNG; 6 s 640x360 testsrc clip, torchcodec path), two server processes per arm: images identical on every run; main's video replies identical across its two processes; the PR differed from main on 1 of 3 video prompts in each process (a different prompt each time, e.g. "... a horizontal rainbow bar, and a pixelated countdown timer" vs "... a circular color wheel, and a horizontal rainbow bar, with a digital counter"). Cause not established; the PR adds probe_video and a budget check before fetch_video on this path. Could be cross-process noise that main's two runs happened not to show

Other

  • Regressions: server healthy after every case on both arms; normal-size images answer as before.
  • Not tested: MSTAR_MAX_VIDEO_PIXELS refusal (V-JEPA 2, Cosmos3, Qwen3-Omni video paths) and the Qwen3-Omni whole-clip fallback reader (time); Waypoint and FLUX.2 klein loaders.

@arun-elr

Copy link
Copy Markdown
Contributor Author

Thanks for the live test.

Values unchanged for normal inputs: ⚠️ [...] the PR differed from main on 1 of 3 video prompts in each process (a different prompt each time [...]). Cause not established

On the torchcodec path the PR only adds a header read (probe_video) and a pixel count before the same fetch_video call, so the frames should be the ones main decodes. A changed input would also hit the same prompts in both processes. The description now says this, marks it as not confirmed, and lists a direct frame comparison under Not tested.

Not tested: MSTAR_MAX_VIDEO_PIXELS refusal (V-JEPA 2, Cosmos3, Qwen3-Omni video paths) and the Qwen3-Omni whole-clip fallback reader (time); Waypoint and FLUX.2 klein loaders.

Added to a new Not tested line. The V-JEPA 2 refusal was served on a B200 (in the description); the others rest on the CPU tests.

@arun-elr arun-elr mentioned this pull request Oct 17, 2026
2 tasks
@arun-elr
arun-elr force-pushed the hardening/media-decode-limits branch from 9687e9a to b0e359a Compare October 17, 2026 20:38
@arun-elr

Copy link
Copy Markdown
Contributor Author

Hardening: the video budget counted pixels but not frames.

Each decoded frame costs about 2 KB of tensor and frame objects whatever its size, so a 4.2 MB clip of 300k 2x2 frames took 596 MB through the Cosmos3 PyAV fallback, and the default 2^30 cap admitted about 268M such frames. VideoBudget now charges every frame at least 64x64 pixels, which bounds the frame count at about 262k under the default and leaves frames of 64x64 or larger (every input in the served runs) unchanged. Covered by test_a_tiny_frame_counts_as_the_minimum and test_cosmos3_pyav_fallback_counts_tiny_frames.

@vasilevklart vasilevklart left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested b0e359ad merged into main on h200s (Qwen3-Omni, Cosmos3-Edge, V-JEPA 2, Qwen3.5) + the CPU tier on Laude, 3bab74e1 (64x64 minimum per frame) re-checked on CPU. Caps work on every loader and normal images decode identical to main

Also settled the Qwen3-Omni question: frames are torch.equal to main on 7 clips, and two main servers differ from each other on the same 6/24 prompts as main vs PR, so it's cross-process noise main already has

A few things:

  1. The cap is per file, not per request. 8 copies of a 236 KB 9000x9000 PNG in one chat request takes the API server to 13 GB (same on main). A per-request total in _process_input (data_worker.py:513) + an attachment cap would close it, here or in a follow-up
  2. MSTAR_MAX_IMAGE_PIXELS above ~179M doesn't do anything, Pillow's own bomb check fires first but the 400 still names the knob's limit. Setting Image.MAX_IMAGE_PIXELS to it in check_media_limits() fixes it
  3. BMP / TIFF still 500 in decode_image (media_limits.py:98), formats=("PNG", "JPEG", "WEBP", "GIF") on the check's Image.open turns them into the 400
  4. worth putting the 1080p ceilings in the MSTAR_MAX_VIDEO_PIXELS row: Cosmos3 refuses 1080p30 over 17 s, Qwen3-Omni over 259 s
  5. nits: read_video_whole_budgeted belongs in qwen3_omni/, probe_video scans the whole file (9.6 vs 2.0 ms with seek_mode="approximate"), vjepa2_model.py:635 checks after allocating

fyi qwen-omni-utils' own fallback reader is already broken on main (torchvision 0.27.1 has no read_video), so yours is the only one that works, worth a line in the description

LGTM otherwise

@arun-elr
arun-elr force-pushed the hardening/media-decode-limits branch from 3bab74e to 3836bc8 Compare October 18, 2026 10:10
@arun-elr

Copy link
Copy Markdown
Contributor Author

Thanks for testing this so thoroughly. Changes since the approval: rebased on main, plus the five points below. A request with one PNG, JPEG, still WebP or GIF file, or one video, is handled as before.

  1. The cap is per file, not per request. 8 copies of a 236 KB 9000x9000 PNG in one chat request takes the API server to 13 GB

Fixed: _process_input now loads each file inside a RequestMediaBudget, so MSTAR_MAX_IMAGE_PIXELS and MSTAR_MAX_VIDEO_PIXELS hold for a request's files together. The 8 copies are refused at the second header, before it is decoded. A new MSTAR_MAX_MEDIA_FILES (default 16, all modalities) refuses a request with more files before any is decoded. A file is charged the largest single check made for it, so Qwen3-Omni's header estimate and the whole-clip fallback after it are not added together. This is part of #590; a decoded total for audio and per-model file counts stay open there.

  1. MSTAR_MAX_IMAGE_PIXELS above ~179M doesn't do anything

Fixed: check_media_limits() raises Image.MAX_IMAGE_PIXELS to the cap when the cap is higher (a lower cap leaves Pillow's limit alone). Test: a 200M-pixel header passes at MSTAR_MAX_IMAGE_PIXELS=400000000.

  1. BMP / TIFF / animated WebP still 500 in decode_image

Fixed: the header check opens with formats=("PNG", "JPEG", "WEBP", "GIF"), so BMP and TIFF are a 400. An animated WebP still opens as WEBP under that list, and torchvision refuses it, so it is now read with Pillow as its first frame, the same as a GIF.

  1. worth putting the 1080p ceilings in the MSTAR_MAX_VIDEO_PIXELS row

Done: the row now gives both (Cosmos3 over about 17 s, Qwen3-Omni over about 259 s, at 1080p30).

  1. nits: read_video_whole_budgeted belongs in qwen3_omni/, probe_video scans the whole file, vjepa2_model.py:635 checks after allocating

Done, all three: the reader is now mstar/model/qwen3_omni/video_reader.py; probe_video opens torchcodec with seek_mode="approximate"; V-JEPA 2 drops the check after the decode, and when the header has no frame size it decodes one sampled frame to learn it and checks before decoding the rest.

fyi qwen-omni-utils' own fallback reader is already broken on main (torchvision 0.27.1 has no read_video)

Added to the description.

Tests: test_media_decode_limits.py 34 passed, 1 skipped (Qwen3-Omni needs flashinfer to import); CPU core 1580 passed, 110 skipped, and the same 2 test_wan_vae_decode.py failures as on main in this environment; ruff clean.

@arun-elr

Copy link
Copy Markdown
Contributor Author

@vasilevklart, two gaps in this PR's loaders came up while checking #647, which is stacked on it. Both are fixed in the top two commits:

  • Qwen3-Omni, no frame count in the header. When probe_video gets no frame count (an MKV or WebM without one), load_video charged nothing, while decord or torchcodec still decoded the sampled frames, so such a clip was not held to MSTAR_MAX_VIDEO_PIXELS. The sampled readers now run only after the header charge; otherwise fetch_video falls back to the bounded whole-clip reader, which counts every frame as it decodes.
  • FLUX.2 klein, BMP and TIFF. The shared header check allowed only the formats torchvision decodes, so FLUX.2 klein, which decodes with Pillow, refused BMP and TIFF uploads that main loads. It now checks and opens images with the Pillow set (PNG, JPEG, WebP, GIF, BMP, TIFF); the torchvision loaders still answer BMP and TIFF with a 400.

Tests: 5 new cases in test_media_decode_limits.py, all failing on the previous head: a real header-less MKV through Qwen3OmniModel.load_video is refused over a lowered cap and counted frame by frame at the default, and FLUX.2 klein loads BMP and TIFF and still checks their header. The file is 42 passed; CPU core 2167 passed, 362 skipped, 2 failed (the two Wan VAE cases that fail on main on this host too). With qwen-omni-utils 0.0.9 and decord 0.6.0, the MKV is now refused at a 3-frame cap and loads at the default.

A quick re-look at the two commits would be much appreciated.

@arun-elr
arun-elr force-pushed the hardening/media-decode-limits branch from 108c690 to 3ae04fe Compare October 19, 2026 10:26

@merceod merceod left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Served on klone (4ec834f for the cells; the two newest commits, the FLUX.2-klein BMP/TIFF fix and the Qwen3-Omni clip without a header frame count, were read, not served). Qwen3-Omni: the 20000x20000 PNG bomb is a 400 in 9 ms where main took 2.45 s and +4.3 GB and then a 500; 16-bit PNGs read right after 4ec834f (the earlier heads turned them white). Cosmos3: the full-decode loaders refuse after decoding up to the cap, +3.1 GB against main's +26.9 GB. CPU tests pass, ruff clean. Not run: Qwen3-Omni video (the venv lacks qwen_omni_utils), FLUX.2-klein, Waypoint and V-JEPA 2.

Notes: the 89.5 MP default refuses 108 and 200 MP phone photos, as documented. The full-decode loaders bound memory after the fact rather than before, documented too.

@arun-elr
arun-elr force-pushed the hardening/media-decode-limits branch 2 times, most recently from 5fb1685 to de029d3 Compare October 19, 2026 16:41
Check an image's header against MSTAR_MAX_IMAGE_PIXELS before decoding it,
and count decoded video frames against MSTAR_MAX_VIDEO_PIXELS, so a request
is refused with a 400 before its media is held in memory.
…he video default to 2^30

A bad MSTAR_MAX_IMAGE_PIXELS or MSTAR_MAX_VIDEO_PIXELS now stops the data
worker at startup instead of turning every media request into a 400. The
docs rows move to their own section and name the functions that read them.

2^28 refused 31 of the 34 clips in a default Video-MME run (640x360, up to
2 min) on Cosmos3, whose loader decodes every frame. 2^30 fits all of them.
qwen-omni-utils' fetch_video falls back to its torchvision reader when
torchcodec or decord raises, and FORCE_QWENVL_VIDEO_READER=torchvision
selects it. That reader decodes every frame, while the Qwen3-Omni loader only
checked the frames it samples, and a header can understate the clip. The
reader is replaced with one that decodes the same frames with PyAV and
samples them the same way, counting each frame against the budget as it is
decoded.
The video budget summed pixels only, but each decoded frame costs about
2 KB of tensor and frame objects whatever its size: a 4.2 MB clip of
300k 2x2 frames took 596 MB through the PyAV fallback, and the default
2^30-pixel cap admitted about 268M such frames. A frame now counts as at
least MIN_FRAME_PIXELS (64x64), which bounds the frame count at 262k
under the default cap and leaves frames of that size or larger
unchanged.
The image and video pixel caps now hold for all of a request's files
together, and MSTAR_MAX_MEDIA_FILES (default 16) refuses a request with more
files before any is decoded (mstar-project#590). The startup check lifts Pillow's own
bomb check to the image cap, the header check only opens PNG, JPEG, WebP
and GIF, and an animated WebP is read with Pillow as its first frame.

Also: the whole-clip reader moves to qwen3_omni/, probe_video reads
torchcodec's header without a scan, and V-JEPA 2 checks before decoding
when the header has no frame size, instead of after.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants