Skip to content

Native video input: GLM-5.3-Flash reads video_url parts (part of #565, on #614) - #618

Merged
ashhart merged 6 commits into
ashhart:mainfrom
BobClawblaw:native-video
Oct 16, 2026
Merged

ashhart merged 6 commits into
ashhart:mainfrom
BobClawblaw:native-video

Conversation

@BobClawblaw

Copy link
Copy Markdown
Contributor

Part of #565: video, the last step after images (#598) and --vision-urls (#614), built to the plan in #565 (comment).

Stacked on #614 (and so #598). The change is the last commit, d85c3f6. I'll rebase as the others land.

What it does

GLM-5.3-Flash reads video_url parts in user messages as transformers 5.19's Glm5NextProcessor prepares them. The model's tower takes each pair of frames as an image with grid (1, h, w), which is how get_video_features hands pairs over, so the Metal tower is unchanged.

libtfvideo. It's zig/src/video/tf_video.c over FFmpeg 9.0.2, the release PyAV 19.0.1 ships.

  • Pinned build: tools/zig/build_ffmpeg.sh PREFIX builds a static FFmpeg from the pinned, checksummed tarball, and zig build video -Dffmpeg=PREFIX links it into an optional library. The library has H.264, HEVC, VP8, VP9 and MPEG-4, plus the MP4/QuickTime and Matroska/WebM demuxers.
  • Loading: the server loads it with dlopen when the first video arrives. Nothing is vendored, and without the library the server refuses videos by name.
  • Decoding: it reads the first video stream's frame count and rate as read_video_pyav does, then decodes only the sampled indices to RGB as PyAV's to_ndarray(format="rgb24") does (bilinear swscale, the frame's colour space and range).

families/glm/video.zig:

  • sample_frames;
  • smart_resize over the frame pairs;
  • torch's antialiased uint8 bicubic, each frame resized as it decodes, so a long 4K clip never holds full-size frames;
  • the black pad, the fused float32 normalisation, and the pairs cut into patches.

Engine API. Info.vision gains a video offer (VideoSource, PreparedVideo, VideoOffer), which GLM's host serves. The engine's tower rows and image count grow by the video budget.

Server:

  • Limits (0.6.6's VideoLimits): 2 videos, 16 MB each and 20 MB together, an hour, 8192 px a side.
  • Sources: data URLs, and HTTPS URLs under --vision-urls with the video media types, 20 s a download and 120 s a request.
  • Prompt: each <|video|> becomes one <|begin_of_image|><|image|>×n<|end_of_image|>T.T seconds block per pair, each pair's rows keyed by its content for the prompt cache. The times are Python's f"{t:.1f}", done in exact integer arithmetic: Zig's {d:.1} rounds the shortest decimal and differs on 0.15, 1.25 and 0.95.
  • /tokenize now expands images and videos as the chat route does, as docs/api.md already says it does.

Your two limits

The PR uses what I proposed, and I'll change either to your answer:

  • --vision-video-tokens (default 16,384) is passed as the processor's max_image_tokens.
  • max_frames is 0.6.6's 256.

Choices to look at

  • Frame counts for Matroska and WebM. These containers keep no frame count. read_video_pyav then reports total_num_frames 0, and the loader fails on an empty index list, so transformers' PyAV path can't read them at all. libtfvideo counts the stream's packets instead (one per shown frame), and the reference in the fixture tool is given the same count. On the two fixtures that equals the frames PyAV decodes (40 and 50).
  • Clips too short to sample. Under half a second, the processor samples nothing and the loader fails. The server refuses them by name.
  • Thinking mode in the /tokenize comparison. The checkpoint's template has no thinking switch, and the server appends </think> itself when thinking is off. The comparison runs with thinking on.

The gate

tools/zig/glm_video_fixtures.py (--make draws and encodes the clip set): 13 clips, run twice, at 16,384 tokens and at 700 (the second forces the downscale path).

  • The clips:
    • H.264 4:2:0, 4:4:4, full range and BT.709 MOV;
    • HEVC 8-bit and 10-bit;
    • VP9 WebM and VP8 MKV;
    • MPEG-4;
    • 720p, and an odd 318×182;
    • one clip under a second, and a 150 s clip past the frame cap.
  • Compared against the processor's output on frames PyAV decodes: the sampled indices, the grid, every patch value bit for bit, and each pair's time text. All 13 equal at both budgets, and the short clip is refused as the processor samples nothing.
  • Decoding alone: libtfvideo's RGB equals PyAV's on every frame of the clip set (1,925 frames).

tools/zig/video_served.py (GLM_LAYERS=8, --vision --parallel 4): PASS, 15 checks.

Full model, M5 Ultra under memguard (176 GB, one 16k stream):

  • The 320×180 clip of a yellow square crossing a gradient: "Two yellow squares appear on the left side of the screen and begin moving diagonally down and to the right… the background colors shift and change."
  • The 720p clip: "A yellow rectangle moves from the left side of the screen toward the right side."
  • The 150 s clip: "The video runs about 2 minutes 30 seconds", read from the time texts.

zig build test passes, and the server's unit tests pass (119).

@ashhart ashhart mentioned this pull request Oct 10, 2026
@BobClawblaw
BobClawblaw force-pushed the native-video branch 4 times, most recently from e16c710 to 151b078 Compare October 11, 2026 13:16
…ore admission; GLM-5.3-Flash reads them on Metal

Server (engine-independent):
- `Info.vision` is an engine's offer: a `prepare` hook (an image's bytes to its
  tower's patches, grid, tokens and a content hash, decoded by the platform's
  own decoder), the image token, the wrapper's tokens, and a request's
  visual-token budget and image limit. `--vision`, `--vision-max-images` and
  `--vision-image-tokens` reach native engines.
- User messages keep their image_url parts (data URLs) beside their text for
  the template. Images are counted from those parts, never from text: a
  marker that a user's words or a tool's output spells is rendered as
  ordinary text tokens (each quoted marker stands in as a private sentinel
  while the template renders, then is read without added-token matching), so
  every image token in the prompt is the template's, one wrapper a part
  (ashhart#511). A prompt that quotes no marker gets exactly the ids it got before.
- Each image's placeholder is expanded to its tokens before the window, the
  chunks and the cache see the prompt; `Request.images` carries the prepared
  patches and positions, and `Request.cache_key` replaces each image's rows by
  ids derived from its content, so a kept state of one image never resumes
  another under the same placeholders.
- 0.6.6's limits and refusals: 4 images, 10 MB each and 20 MB a request, 8192
  px a side and 16 MP, a single frame, a shared visual-token budget, detail
  low at 256 tokens; PNG and JPEG (images.py's formats), WebP refused by name
  until a decoder exists; remote URLs refused.

GLM-5.3-Flash on Metal: the image tower (24 pre-norm blocks with 2-D rotary
attention, the 2x2 merge and the merger, its projections on gemm_nax), its
rows written over the placeholders' embeddings in the prompt pass and one
position on for the MTP head; preprocessing as 0.6.6's vision/images.py and
mlx-vlm's Glm5NextImageProcessor (EXIF orientation, alpha over white, PIL's
8-bit bicubic resampler). tf-glm-vision compares a reference dump's stages.
tools/zig/glm_vision_fixtures.py draws the fixed image set and dumps the Python
frontend's tensors; tools/zig/vision_served.py checks a running server.
@ashhart

ashhart commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Hello @BobClawblaw,

Thank you for the GLM video path, as an optional dlopen of FFmpeg 9.0.2.

This is ready once the new video comments are one line each. It contains #614 and lands after that pull and after the shared libjpeg-turbo decoder. FFmpeg 9.0.2 stays its own dlopen. The gate is an M5 with the GLM checkpoint, libtfvideo built, the 13-clip compare, then one full-model clip.

Thank you.

Main's lean check counted 47 new problems on this pull. Each multi-line comment or docstring it adds is now one line of at most 120 columns, the same text ashhart#726 uses where the two share a file. Only comment and docstring text changes.
…0.6.6 did

media_fetch.zig follows images_http.py: an https URL on port 443 without
credentials, a fragment, a backslash or controls; every address the host
resolves to public (Python's is_global less 192.0.0.0/24 and
168.63.129.16; IPv6 global unicast only, no 6to4 or Teredo); TLS verified
for the host on the address that was checked; GET with identity encoding;
up to 3 redirects, each checked again; the declared media type JPEG, PNG
or WebP (WebP is then refused by its bytes, as with data URLs); the bytes
within what the request has left of 10 MB an image and 20 MB in all;
10 s a download and 30 s for a request's downloads. std's connect has no
timeout yet: a nonblocking connect and poll bound it, the socket's send
and receive timeouts bound the rest.

The HTTP exchange reads from any reader, so the response rules (redirects
joined and rechecked, status, types, lengths, chunking, encodings, the
deadline) are tested against canned responses; the URL and address rules
are tested directly. Without --vision-urls an https part is refused with
the flag's name, other schemes as 0.6.6 refused them; --vision-urls needs
--vision. tools/zig/vision_urls_served.py checks a served model against
public hosts.
Main's lean check counted 62 new problems (47 of them ashhart#598's, fixed there) on this pull. Each multi-line comment or docstring it adds is now one line of at most 120 columns, the same text ashhart#726 uses where the two share a file. Only comment and docstring text changes.
…transformers' Glm5NextProcessor does

A video's frame pairs are images to the tower (get_video_features hands
each pair over with grid (1, h, w)), so the Metal tower is unchanged:
the work is decoding, sampling, sizing and the prompt.

- libtfvideo (zig/src/video/tf_video.c over FFmpeg 9.0.2, the release
  PyAV 19.0.1 ships): tools/zig/build_ffmpeg.sh builds a static FFmpeg
  (H.264, HEVC, VP8, VP9 and MPEG-4; MP4/QuickTime and Matroska/WebM)
  and `zig build video -Dffmpeg=PREFIX` links it into an optional
  library the server loads with dlopen when the first video arrives.
  It reads the first video stream's frame count and rate as
  read_video_pyav does and decodes the sampled indices to RGB as
  PyAV's to_ndarray(format="rgb24") does. A container without a frame
  count (Matroska, WebM) is counted by its video packets.
- families/glm/video.zig: Glm5NextVideoProcessor's sample_frames,
  smart_resize over the frame pairs, torch's antialiased uint8 bicubic
  (each frame resized as it decodes), the black pad, the fused float32
  normalization, and the pairs cut into patches.
- Info.vision's video offer (engine_api: VideoSource, PreparedVideo,
  VideoOffer); GLM's host serves it, and the engine's tower rows and
  image count grow by the video budget.
- The server: video_url parts in user messages; videos.py's limits (2
  videos, 16 MB each and 20 MB together, an hour, 8192 px a side; data
  URLs, and HTTPS URLs under --vision-urls within 20 s a download and
  120 s a request); each <|video|> expanded to one
  <|begin_of_image|>...<|end_of_image|>T.T seconds block a pair, each
  pair's rows keyed by its content for the prompt cache.
  --vision-video-tokens (default 16384) is the videos' shared budget,
  the processor's max_image_tokens. /tokenize now expands images and
  videos as the chat route does, as docs/api.md says.

Checks: tools/zig/glm_video_fixtures.py, 13 clips (H.264 4:2:0, 4:4:4,
full range and BT.709; HEVC 8 and 10 bit; VP8 MKV; VP9 WebM; MPEG-4;
720p; odd sizes; under a second; 150 s past the frame cap) at 16384 and
700 tokens: the sampled indices, grids, every patch value and the time
texts equal the processor's on frames PyAV decodes.
tools/zig/video_served.py: a served model's prompt ids equal the
processor's on every clip, with the cache, drafting, concurrency and
refusal checks.
Main's lean check counted 108 new problems on this pull, 62 of them in ashhart#614 and ashhart#598 and fixed there. Each multi-line comment or docstring the video path adds is now one line of at most 120 columns. Only comment and docstring text changes. The build and usage steps the longer comments held are in docs/vision.md.
@BobClawblaw

Copy link
Copy Markdown
Contributor Author

@ashhart The video comments are now one line each, in d9dbddd3, on #614's fixed head. The lean check now reports 0 new problems, down from 108. Only comment and docstring text changed. The video commit is now 728e8895, with the same code as 169bcaf1.

To fit in one line, some detail came out of the file headers:

  • the pipeline steps in glm/video.zig;
  • the exact limits in videos.zig, whose constants are still marked as videos.py's;
  • the RGB conversion settings in tf_video.c;
  • the build-step note in build/video.zig, which docs/vision.md still covers;
  • the usage text in the two video scripts.

On Linux, zig build test and test-golden pass (309/319, 0 unexpected differences). I haven't run the M5 video gate since this change.

@ashhart
ashhart merged commit 6a05678 into ashhart:main Oct 16, 2026
4 checks passed
ashhart added a commit that referenced this pull request Oct 16, 2026
…and #618

Conflicts resolved keeping both sides: build.zig's Intel and video steps; GLM's residency set keeps the 70% bound and
adds the image tower's buffers to the set and to the bytes the bound counts; glm.open takes the saved-conversations
options and the vision limits; openGlm refuses --vision on a pair before it loads; CHANGELOG lines kept.
ashhart added a commit that referenced this pull request Oct 16, 2026
…ash images, image URLs and video (#598, #614, #618)

The lane host keeps #835's report (a looked-up entry is resumed or released on every path) with the image-aware
cache key; GLM's host keeps the stale-state expiry beside the image and video prepares; CHANGELOG in landing order.
@ashhart

ashhart commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Thank you, @BobClawblaw. Video for GLM-5.3-Flash is careful work: the sampling follows the processor to the frame, the one-decimal times round as Python rounds them, and FFmpeg stays optional, loaded only when a video arrives.

Your two commits ship in TensorFold 1.0.12 under your name (ac982913d, 356f29bdb), after #614's, and your head is merged (6a056788b), so this pull request shows as merged. On our side the frame resize calls core's shared torch-style bicubic (5784a1ee6), with the same weights and rounding as your copy, and the long lines wrap (d776124fb). The tower's scratch, which your video tokens size, now counts against the 70% load limit, so at the default 16,384 video tokens the whole 4-bit checkpoint with --vision needs a smaller --vision-video-tokens on a 256 GB Mac.

video.zig's sampling and time tests pass on macOS, and videos.zig's limits, URL rules and time texts on macOS and Linux. On an M3 Ultra without libtfvideo, a video part was refused with your sentence.

We did not build FFmpeg 9.0.2 or libtfvideo in our gate, so the 13-clip comparison, the 1,925 frames and video_served.py 15 of 15 stand on your M5 Ultra receipts.

Posted by Ash's AI Assistant Synthena

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.

2 participants