I was planning on working on a redesign of a photo site, and wanted to use JPEG-XL as the preferred image format for storage. The only implementation I know of is the libjxl reference implementation written in C++. Alas, JPEG successors have not had a great security track record recently, and I would much prefer not to run this code on my server.
While looking into libjxl, I found it can be compiled to WASM. Seems like this could be the needed solution to my safety concerns. Hook this into my go server with a wasm runtime, and I’d feel a lot more comfortable connecting this to the internet.
I think it’s beyond impressive how JPEG has held up over time. Over 30 years later, the same basic algorithm still performs. I’m still happy to use it for image presentation, but it’s not great for archival. The 8 bit dynamic range is limiting. Good results can be achieved with tone mapping, but once applied and saved, JPEG images essentially become read only. Better compression ratios are certainly nice to have, but storage is cheap enough I don’t worry about keeping lots of jpegs.
WebP is right out. Modest compression gain at best, and still 8 bit channels. HEIF (or HEIC, HEVC, whatever we’re calling it today) seems reasonable technically, but it’s a complicated landscape. Maybe the AVIF variant?
Or my pick, JPEG-XL which adds support for more bit depths. It’s designed as a still image format (though it does support multiple frames as well). Browser support is lacking, but we can always work around that.
One interesting point about JPEG-XL is it supports lossless conversion from and to JPEG. You can take a jpeg input, recode it as jxl to save 20% space, then recode it back to jpeg and have exactly the same image data. I think this gives it a leg up as a transition format.
talking through wasm
Talking to a C library through wasm is certainly possible, that’s the whole point, but it can be awkward. Setting up and using a jxl decoder instance requires making a dozen or more function calls. And there’s the matter of preprocessor defined constants.
Instead I wrote my own version of what’s basically the libjxl samples directory, an encode and decode function, except they work on global variables. The host, my program, calls a function named alloc, copies some data into the returned pointer, then calls encode or decode, then copies data out of the wasm memory.
This is very much the opposite of how you’d want to interface with any normal library, but here we can create a new wasm instance as needed. All these globals are really more like class members, in a roundabout way.
libjxl supports streaming output, where you can check for a “need memory” status code, provide it a new buffer, then continue. But it only supports all at once inputs, so there’s not much point to trying to feed data out by calling back out of wasm. All data, in both directions, is copied once in a single block.
There’s a certain amount of fragility, in that the output buffer is the third pointer in an array of pointers, but this code was never touched after the initial write, and I don’t see why it would change or break.
Contrary to my understanding of the documentation, setting up a libjxl encoder to include an alpha channel is quite complicated. It sure sounds like “extra” channels are for anything beyond alpha, and it should default to understanding four channels to mean RGB plus A. However, it does not. You get an API misuse error instead.
The API misuse error contains exactly one bit of information. You misused the API. The only way to get more information out of the library is to rebuild it in debug mode, and then it will print a log of complaints to the console. Sorting this all out took quite a while, building a native C version of my code, running that, etc. Only 8 bit RGBA formats are supported at present, and I’m not looking forward to expanding support.
libjxl was obviously developed on a linux system where memory allocations never fail. There’s at least a few spots where it simply asserts that allocation has succeeded. If it fails, it prints a message and aborts. Calling abort is an unfriendly way for a library to indicate failure. (I discovered this when it tried to print an error message, but my fd_write callback didn’t indicate success, so it kept looping. Fixed that to see the error message, just before it crashed.)
Allocating more memory to a wasm module involves rebuilding with a larger “heap” size. Which I think is preallocated. So there’s a limit to how large we want to make it. It is now set to one gigabyte, but it would be nicer if this were runtime controllable in some way.
Starting up a new wasm runtime per image would be quite costly, so instead there’s a small pool of them. The default go image interfaces are static functions, so they automatically grab a JXL object containing a wasm runtime as needed. The amortized performance is decent enough, imo.
The downside to this approach is if anything goes wrong inside libjxl, it will probably break the wasm instance in weird ways. Like the allocation failure. The error is recoverable in the sense that my program didn’t crash, but that runtime instance is now broken and unusable. Need to do some refinement here to detect such situations.
On the whole though, it should be fairly transparent to any go application code.
The wasm code itself is embedded, gzipped. That’s a megabyte of your memory you’re never getting back.
Despite JPEG-XL’s support for greater bit depths, we’re currently limited to 8 bit here. This requires passing a few more arguments to the encode and decode functions, easy enough, and then wrestling with libjxl to discover the proper means of frobbing. Go has an RGBA64 type, which despite the name is 16 bit channels, stored big endian. A little endian version would be useful for practical purposes.
There’s also no support for direct recoding of jpeg to jxl. Someday.
There’s also a small cmd (literally called cmd) that can encode and decode images.
In the middle of this little endeavor, I found out about rlbox, which compiles C code to WASM and then compiles the WASM back to C. Firefox apparently uses this easy and practical sandbox for some libraries.
I can’t help but notice that Firefox is using rlbox for the spell checker but not libwebp, so I think using it may not be quite so simple. Compiling to WASM just the one time was hard enough for me. Maybe next time.