Skip to content

fx.loadImage fails too_large on any photo past 512x512: decode has no downscale option #162

Description

@sepehr-safari

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.

Activity

  1. changed the title [-]canvas images: decode-time downscaling, animated image playback, and a composed image-loading effect[/-] [+]`fx.loadImage` fails `too_large` on any photo past 512x512: decode has no downscale option[/+] on Jul 27, 2026
  2. sepehr-safari commented on Jul 27, 2026

    @sepehr-safari
    ContributorAuthor

    Narrowed this issue and retitled it.

    Part C shipped in 0.5.4 as fx.loadImage / Cmd.imageLoad — it does what I was hoping for, so I have removed it. I have also dropped the animated-image proposal from here rather than leave it bundled.

    What is left is one ask: a max_dimension decode option, defaulting to off. Restating it against 0.6.1 made the case sharper than when I first filed, because fx.loadImage inherits the same wall — it accepts sources up to 1.25 MiB but decodes at native size into a 1 MiB pixel budget, so a 640x480 photo comes back .too_large. The body has the details.

  3. sepehr-safari commented on Aug 16, 2026

    @sepehr-safari
    ContributorAuthor

    @ctate any ideas about this one?

  4. ctate commented on Aug 17, 2026

    @ctate
    Collaborator

    Hey @sepehr-safari - I've added some potential fixes in v0.9.2. Please try it out and let me know if it works for you!

  5. sepehr-safari commented on Aug 19, 2026

    @sepehr-safari
    ContributorAuthor

    @ctate it works, thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions