Narrowed 2026-07-27. This issue originally bundled three proposals. Part C (a composed image-loading effect) shipped in 0.5.4 as fx.loadImage / Cmd.imageLoad — thank you, it is exactly the shape I was asking for. I have cut this issue down to the one remaining item, restated against 0.6.1, so there is a single thing to accept or decline. The animated-image proposal is dropped from here; I will file it separately if it is ever worth it.
fx.loadImage decodes at native size and has no way to ask for a smaller decode, so it answers .too_large for essentially every real-world photo — including images far smaller than the 1.25 MiB source bound it accepts.
What happens
The decoded-pixel ceiling is 1 MiB (canvas_limits.zig:106, max_registered_canvas_image_pixel_bytes), which at RGBA8 is exactly 512×512. Nothing in the pipeline downscales, and LoadImageOptions (effects.zig:2797-2841) has no option to request one — its seven fields are id, path, url, cache_path, expected_bytes, timeout_ms, on_result.
So a 640×480 photo — smaller than anything a feed actually serves — decodes to 1.17 MiB and comes back .too_large (effects.zig:2365, error.ImageTooLarge => .too_large). It fails even though it is drawn at maybe 300pt, and even though its encoded bytes sailed through the 1.25 MiB source bound.
The result is that the new dynamic-image pipeline can load avatars and icons, but not photos, which is the case it most looks like it is for.
Why the bound cannot be raised instead
The source bound is derived from the decoded one, and the comment says why (effects.zig:800-806):
/// larger than that cannot decode inside the registered-image budget on
/// any host, so hauling more bytes would only defer the same
/// `.too_large` answer.
pub const max_effect_image_bytes: usize = canvas_limits.max_registered_canvas_image_pixel_bytes +
canvas_limits.max_registered_canvas_image_pixel_bytes / 4;
That reasoning is exactly right, and it is precisely what decode-time downscaling changes: with a target size, a larger source can decode inside the budget, and hauling those bytes stops being pointless. The 1 MiB budget itself does not need to move.
Proposed fix
An options parameter on both entry points, defaulting to today's behavior:
fx.loadImage(.{ .id = id, .url = url, .max_dimension = 512, .on_result = ... })
registerImageBytes(id, bytes, .{ .max_dimension = 512 })
max_dimension = 0 (the default) keeps the current path byte-identical, so no existing app or golden changes. When set, the decoder is asked for a thumbnail at that bound and the registered image is whatever that yields.
All three platform decoders do this natively, at decode time, for less than the cost of decoding full-size:
- macOS ImageIO —
CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize
- Linux gdk-pixbuf —
gdk_pixbuf_new_from_stream_at_scale
- Windows WIC —
IWICBitmapScaler (Fant)
No new dependencies, and the budget stays a hard bound — it just becomes a policy the app can satisfy rather than a wall it hits.
What I do today
I vendor stb_image and stb_image_resize2 and downscale in-process before registering. It works, but it means shipping a second copy of a decoder the platform already has, and it has to happen before registerImageBytes, so fx.loadImage's whole point — bytes never materializing in app memory — is lost for exactly the images that need it most.
Happy to contribute the implementation if the direction is agreeable.
Version: CLI 0.5.3 / framework 57bf56bc in production; every line reference above re-verified against v0.6.1.
Narrowed 2026-07-27. This issue originally bundled three proposals. Part C (a composed image-loading effect) shipped in 0.5.4 as
fx.loadImage/Cmd.imageLoad— thank you, it is exactly the shape I was asking for. I have cut this issue down to the one remaining item, restated against 0.6.1, so there is a single thing to accept or decline. The animated-image proposal is dropped from here; I will file it separately if it is ever worth it.fx.loadImagedecodes at native size and has no way to ask for a smaller decode, so it answers.too_largefor essentially every real-world photo — including images far smaller than the 1.25 MiB source bound it accepts.What happens
The decoded-pixel ceiling is 1 MiB (
canvas_limits.zig:106,max_registered_canvas_image_pixel_bytes), which at RGBA8 is exactly 512×512. Nothing in the pipeline downscales, andLoadImageOptions(effects.zig:2797-2841) has no option to request one — its seven fields areid,path,url,cache_path,expected_bytes,timeout_ms,on_result.So a 640×480 photo — smaller than anything a feed actually serves — decodes to 1.17 MiB and comes back
.too_large(effects.zig:2365,error.ImageTooLarge => .too_large). It fails even though it is drawn at maybe 300pt, and even though its encoded bytes sailed through the 1.25 MiB source bound.The result is that the new dynamic-image pipeline can load avatars and icons, but not photos, which is the case it most looks like it is for.
Why the bound cannot be raised instead
The source bound is derived from the decoded one, and the comment says why (
effects.zig:800-806):That reasoning is exactly right, and it is precisely what decode-time downscaling changes: with a target size, a larger source can decode inside the budget, and hauling those bytes stops being pointless. The 1 MiB budget itself does not need to move.
Proposed fix
An options parameter on both entry points, defaulting to today's behavior:
max_dimension = 0(the default) keeps the current path byte-identical, so no existing app or golden changes. When set, the decoder is asked for a thumbnail at that bound and the registered image is whatever that yields.All three platform decoders do this natively, at decode time, for less than the cost of decoding full-size:
CGImageSourceCreateThumbnailAtIndexwithkCGImageSourceThumbnailMaxPixelSizegdk_pixbuf_new_from_stream_at_scaleIWICBitmapScaler(Fant)No new dependencies, and the budget stays a hard bound — it just becomes a policy the app can satisfy rather than a wall it hits.
What I do today
I vendor stb_image and stb_image_resize2 and downscale in-process before registering. It works, but it means shipping a second copy of a decoder the platform already has, and it has to happen before
registerImageBytes, sofx.loadImage's whole point — bytes never materializing in app memory — is lost for exactly the images that need it most.Happy to contribute the implementation if the direction is agreeable.
Version: CLI 0.5.3 / framework
57bf56bcin production; every line reference above re-verified against v0.6.1.