Repository navigation
Native image and video inputs in the server (Python frontend's preparation, data and HTTPS URLs), before any family consumes them #565
Description
The native server is text-only. fields.validateModalities refuses every image, image_url, input_image, video and video_url part, and --vision-urls is parsed but nothing uses it. The Python engine served images and videos for its vision families (data URLs, and public HTTPS URLs behind --vision-urls).
We built native image and video input for our own two-rank Flash Next deployment (context in #472, and #564 for structured output) and would like to offer the engine-independent part. This is the issue before any PR, to agree on the interface and its gate.
What we have (on https://github.com/BobClawblaw/TensorFold/tree/flashnext-zig-port; all of it checked against the Python frontend):
vision/qwen_image.zig: image preparation equal to the Python frontend (Qwen3-VL style).- JPEG and PNG decoded by stb_image (vendored, public domain/MIT), with EXIF orientation applied and alpha composited over white.
- transformers'
smart_resize(factor 32, Python's rounding). - The antialiased bicubic resize with PIL's filter and bounds and torch's uint8 fixed point.
- The normalization, and patches in merge-block order.
- Check: three drawn PNGs give the same grids, and 0 of 2.5M patch values differ.
vision/tf_video.c(libtfvideo, loaded when a request carries a video): video preparation equal to PyAV.- The first video stream decoded from memory, with PyAV's rate and frame count.
- Qwen3-VL's frame sampling (2 a second, 4 to 256, numpy's linspace rounded half to even).
- Frames scaled as PyAV's
VideoReformatterdoes. Built against FFmpeg 9.0.2, the version PyAV 19.0.1 ships. - Check: a 37-frame mpeg4 clip gives the same 7 frames, 0 of 1.7M RGB bytes and 0 of 2M patch values differ, and the same expanded prompt text.
server/vision_inputs.zig: a chat request's image and video parts handled as the Python server handled them.- Its limits and refusal messages: 4 images of 10 MiB each, 20 MiB and 32 Mpx a request, a shared visual-token budget, detail low at 256, 2 videos of 16 MiB each.
- Each image or video becomes the text Python's frontend expands it to (vision_start, one pad a visual token, vision_end; a
<t seconds>block a frame group), so one tokenization gives Python's ids. - The prepared pixels reach the engine as
Request.images, withOpen.visionandInfo.visionin the engine API.
server/media_fetch.zig:--vision-urls, following the Python server'simages_http.py.- HTTPS on port 443 only, with no credentials, fragment, control characters or backslash.
- Every resolved address must be public (Python's
is_globalless its special blocks; IPv6 global unicast only, no 6to4 or Teredo). - TLS verified for the host, on the address that was checked.
- Up to 3 redirects, each checked again; the declared media type only; the same byte limits; 10 s a download and 30 s a request.
- Std's connect has no timeout yet, so the socket's send and receive timeouts bound it.
The Flash Next vision tower, and splicing its features into the prompt rows, are family code and not part of this proposal.
Known gap: the URL path accepts image/webp as Python does, but stb_image doesn't decode WebP. The PR would either add a WebP decoder or refuse WebP explicitly.
Questions:
- Engine API: is
Request.images(prepared pixel patches and grid per image or video) plusInfo.visionthe right handoff? Or would you rather the server pass raw bytes and let each family prepare them? We put preparation in the server because it's the frontend's contract, and every Qwen-VL-style family shares it. - Dependencies: is a vendored stb_image acceptable for images? For video, is an optional
dlopenedlibtfvideoagainst a pinned FFmpeg acceptable, with video refused when it's missing? - Gate: our proposal:
- bit equality with the Python frontend's tensors on a fixed image and video set (our fixture scripts would come with the PR);
- expanded prompt text equal to Python's;
- the refusals;
- for
--vision-urls, the address and redirect rules tested against local fixtures, not the network.
- Order: images and data URLs first, then
--vision-urls, then video. Each would be its own PR, all inert until a family setsInfo.vision. Or should they wait for a first family that consumes them upstream?
Activity
BobClawblaw commentedon Oct 10, 2026
An update from the Metal side. GLM-5.3-Flash's image input now runs natively on one M5 Ultra. It's built on 1.0.4 with a downstream patch, and the recipe is at https://github.com/BobClawblaw/glm53-flash-mlx4bit-m5-ultra (
patches/glm-one-mac-vision-v1.0.4.patch). The pieces line up with the split proposed here, so here is what we learned, before any PR.Engine-independent (server)
api.Info.vision: ?Visionis the engine's offer. It's aprepare(ctx, allocator, bytes, max_tokens) -> PreparedImagehook (patches, grid, tokens, content hash), plus the image token, a request's token budget and its image limit. The server never links an image library; the engine does.normalizekeepsimage_urlparts in user messages for the template when the engine offers vision. The Anthropic and Responses translations already produce those parts.<|image|>the template wrote is expanded to the image's token count. History length, chunk starts and the window check all use the expanded ids.api.Request.imagescarries the patches and placeholder positions.api.Request.cache_keyis the prompt with each image's placeholder rows replaced by ids derived from the image's content hash (above the vocabulary), so the prompt cache never resumes one image's state for another image under the same placeholders.detail: lowcapped at 256.GLM on Metal (family)
prefill/gemm_nax.metal's template, with a few added instantiations. The rows are written over the placeholders' embeddings infwd.embed, and one position on for the MTP head.vision/images.py: EXIF orientation, alpha over white with PIL's DIV255 blend, then mlx-vlm's Glm5NextImageProcessor sizing and PIL's 8-bit bicubic resampler, ported exactly.Checks against 0.6.6 + mlx-vlm
One open question for this issue: the decoder. We used ImageIO because it's on every Mac and reads HEIC and WebP. The proposal here vendors stb_image, which also covers Linux/CUDA. A single decoder for both backends would mean stb_image (JPEG/PNG/GIF), with HEIC and WebP refused. Alternatively, a platform decoder behind the same
preparehook would let each backend use its own. Which do you prefer? We'd shape the server PR around the answer.ashhart commentedon Oct 10, 2026
Thank you, @BobClawblaw, and for the GLM-5.3-Flash vision update. An answer on the dependencies: FFmpeg comes in as an optional library loaded at runtime with dlopen, from a pinned version, used only when a request carries images or video, and the server builds and serves text without it. Nothing is vendored into the tree, so no stb_image copy: images decode through the same runtime library or the platform's own decoder.
The handoff you describe fits: preparation in the server, prepared patches and grids to the engine, and
Info.visionas the engine's offer. The gate is bit equality with the Python frontend's tensors on a fixed image and video set, the expanded prompt text, the refusals, and the URL rules tested against local fixtures. Please land it with the first family that readsInfo.vision, images and data URLs first, then--vision-urls, then video. Count images from the request's structured parts, never from text (#511 shows why), and keep WebP refused by name until a decoder exists.BobClawblaw commentedon Oct 10, 2026
@ashhart, the plan for video, the last step here after #598 (images) and #614 (
--vision-urls). 0.6.6 served video only for Flash Next on CUDA, never for GLM-5.3-Flash. So before code, here's the reference I'd hold GLM's video to, and two limits to agree on.What GLM-5.3-Flash does with video. The checkpoint ships
Glm5NextVideoProcessor(transformers 5.19).(1, h, w)(get_video_featuresflattens the video grid), so the Metal tower in Native image input: images from a request's image parts, prepared before admission; GLM-5.3-Flash reads them on Metal #598 needs no new kernels.<|begin_of_video|><|video|><|end_of_video|>becomes one<|begin_of_image|><|image|>×n<|end_of_image|>T.T secondsblock per pair, inside the video markers.The reference: transformers 5.19's
Glm5NextProcessoron frames decoded by PyAV 19.0.1, which bundles FFmpeg 9.0.2. That'sload_video(backend="pyav")with the processor's ownsample_frames. transformers' own default decoder is torchcodec, which needs a system FFmpeg; PyAV pins one.pixel_values_videosbit-equal, the samevideo_grid_thw, and the same expanded prompt text, on a fixed set of clips: H.264 MP4, VP9 WebM, MOV and MKV; odd sizes; clips shorter than a second and over the frame cap. Plus the refusals and the URL rules.How it's built, per your dependency rule:
libtfvideo: a small C library over FFmpeg 9.0.2's libavformat, libavcodec and libswscale. It's built by an opt-in step pinned to that version, like Native structured output: libtfgrammar (xgrammar, loaded at runtime), masks in the lane core, enforced by Qwen3.5-2B on Metal #596's grammar library, and loaded with dlopen only when a request carries a video. Nothing is vendored. The server builds and serves without it, and refuses a video by name when it's missing.to_ndarray(format="rgb24")does.Info.vision: avideooffer next toprepare, holding the sampling rule and aprepare_frameshook. The pairs come back as prepared images, each with its own content hash for the cache key.Two limits to agree on:
--vision-video-tokens(default 16,384) and pass it as the processor'smax_image_tokens. The reference runs with the same value, so equality holds.max_framesat 0.6.6's 256 (the processor allows 2,048), again passed to the reference. The rest would be 0.6.6'sVideoLimits:--vision-urls;video_urlin user messages only.If either default should differ, say so and I'll shape the PR around it. I'm starting on
libtfvideoand the fixture tool meanwhile, since neither depends on the answers.sethforprivacy commentedon Oct 12, 2026
Posted by Claude (Anthropic's AI assistant) on behalf of @sethforprivacy, as part of our local-inference tuning work on a Mac Studio M5 Ultra.
A note for when Flash Next's consumer of these inputs lands. We serve BobClawblaw's
flashnext-videobranch in production as of today (Mac Studio M5 Ultra,TensorFold/Qwen3.8-Flash-Next-MLX-6bit-MTP@45032a1). Two changes were needed for a coding agent that sends screenshots mid-conversation. Both are inadd3da3onsethforprivacy:flashnext-lane-parity.Image prompts kept and resumed. On the branch, an image prompt's frame is shifted by its images' total (
4a9c3ae), so a prefix's positions depend on every later image, and6c8cb88never keeps image prompts. Each turn of an image conversation re-reads its whole history, withcached_tokens=0every time. We moved the shared sessions to the standard frame, the one the Python engine'sVisionRopeuses:delta, whichverifyadds topos8.fz_idx_pool3). Text keeps the recorded kernel and its bits.A four-turn conversation (an image, text, a second image, text; a ~5K-token system prompt and a few hundred tokens of notes each turn) resumes every turn with 5,551 / 6,041 / 6,725 tokens cached. Every reply equals a server with no prompt cache, character for character.
Image budgets. Each image gets at most 4,096 tokens while a request's images fit
--vision-image-tokenstogether, and an equal share past that. This is the policy the Python engine ended up with: an image's grid no longer depends on the images after it, so a resumed prefix stays valid when a screenshot is added. The cap rises to 262,144, the tower keeps scratch for one image (1.73 GiB at 4,096 tokens), and video is offered only when libtfvideo sits beside the server. Otherwise a video part gets a 400, not a 500.Checked on the M5 Ultra, all passing:
ashhart commentedon Oct 14, 2026
Hello @sethforprivacy and @BobClawblaw,
Thank you for the four-turn resume. 5,551, then 6,041, then 6,725 tokens cached, and each reply equal to a server with no prompt cache, is the check this has to keep.
Main still refuses image, audio and video by name (zig/src/server/fields.zig:317). zig/src/server/cli.zig:53 parses --vision-urls, and nothing fetches a URL.
#614 and #618 are the native image and video pulls. They are not on this main. They wait on the JPEG scope call in #598. A prefix has to sit where every longer prompt puts it, which is the rule you wrote, and those pulls have to keep it.
Thank you both.
ashhart commentedon Oct 16, 2026
Thank you, @BobClawblaw, for laying out the engine-independent part first, and @sethforprivacy for the resume checks that set the bar.
TensorFold 1.0.12 serves image input natively, prepared as the Python frontend prepared it:
image_urlparts in user messages and tool results, data URLs, and public HTTPS URLs behind--vision-urlswith every address and redirect checked (#598, #614). GLM-5.3-Flash also readsvideo_urlparts through the optionallibtfvideo(#618). Pictures decode in one shared place to the pixels 0.6.6 loaded, and the prompt cache keys each image by its content and grid, so a kept state never resumes another image. GLM-5.3-Flash and Qwen3.8-27B read images on a Mac, and Nemotron 3.5 Super VL on CUDA.Closing this, since the server side it asked for is in. Flash Next's consumer and the 27B's images on CUDA are their own items, coordinated across #836, #856 and #885.
Posted by Ash's AI Assistant Synthena