DEV Community

El_Necora
El_Necora

Posted on

Compiling libjxl to WASM so browsers can open JPEG XL files

Chrome shipped JPEG XL behind a flag in 2021, then pulled it in 2022. Firefox has it in Nightly only. Safari supports it. So depending on which browser you happen to have open, a .jxl file is either an image or a file your OS shrugs at.

That's an annoying place for a format to be, because people do end up with these files. Some camera and phone export pipelines produce them, and anyone who has gone down the "let me re-encode my photo library" road has a folder of them somewhere. Then they try to open one on a machine that isn't a Mac and nothing happens.

I wanted a viewer that runs entirely in the browser — no upload, no server touching the file. That part is non-negotiable for me; if you're going to look at your own photos you shouldn't have to POST them to a stranger first. Which means the decoder has to be WASM.

Here's what that actually took, including the part where it broke in production.

Starting with jSquash

@jsquash/jxl is the obvious starting point. It's a nicely packaged WASM build of libjxl and it works. I shipped with it.

Then someone on Reddit tried a 22.4 megapixel file and got:

RuntimeError: index out of bounds
    at toWireType
Enter fullscreen mode Exit fullscreen mode

Not a great error message. The encoder was dying somewhere between 12 and 16 megapixels — under that, fine; over it, dead. The heap can grow to 2GB but it never gets close before falling over, so it's not a simple "raise the limit" fix.

And even below the limit it was slow. A 12MP image took 21 seconds at effort 7. Nobody waits 21 seconds.

So: build libjxl myself.

The build

Standard emscripten setup:

git clone --depth 1 https://github.com/emscripten-core/emsdk.git
cd emsdk && ./emsdk install latest && ./emsdk activate latest
git clone --depth 1 --recurse-submodules --shallow-submodules https://github.com/libjxl/libjxl.git
Enter fullscreen mode Exit fullscreen mode

The cmake invocation is where the interesting decisions live:

source ~/emsdk/emsdk_env.sh && cd ~/libjxl
emcmake cmake -B build-wasm -S . -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_SHARED_LIBS=OFF \
  -DJPEGXL_ENABLE_WASM_THREADS=OFF \
  -DBUILD_TESTING=OFF -DJPEGXL_ENABLE_BENCHMARK=OFF -DJPEGXL_ENABLE_EXAMPLES=OFF \
  -DJPEGXL_ENABLE_MANPAGES=OFF -DJPEGXL_ENABLE_JNI=OFF -DJPEGXL_ENABLE_PLUGINS=OFF \
  -DJPEGXL_ENABLE_VIEWERS=OFF -DJPEGXL_ENABLE_DOXYGEN=OFF -DJPEGXL_ENABLE_TOOLS=ON \
  "-DCMAKE_EXE_LINKER_FLAGS=-sEXPORTED_RUNTIME_METHODS=FS,callMain -sFORCE_FILESYSTEM=1 -sALLOW_MEMORY_GROWTH=1 -sINVOKE_RUN=0 -sEXIT_RUNTIME=0 -sMODULARIZE=1 -sEXPORT_NAME=createCjxl"

emmake make -C build-wasm cjxl -j6
Enter fullscreen mode Exit fullscreen mode

Two of those flags are load-bearing and the rest is just trimming fat.

-DBUILD_SHARED_LIBS=OFF — without it the link fails looking for libjxl_cms.so. Shared libraries in a WASM build don't mean what you want them to mean.

-DJPEGXL_ENABLE_WASM_THREADS=OFF — this one is a real architectural choice, not a workaround. Turning threads on pulls in pthreads, which needs SharedArrayBuffer, which needs you to serve the page with Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers. Those headers put your document in a different agent cluster and quietly break most third-party embeds. If your page is nothing but the tool, go for it. If it has anything else on it, you're choosing between threads and the rest of your page working.

I chose single-threaded. Which leads directly to the next thing.

The effort cliff

libjxl's -e flag goes from 1 to 9. Higher effort, smaller file, more CPU. Conventional wisdom says 7 is a good default.

Single-threaded in WASM, on a 22.4MP image:

effort time
4 ~3s
5 ~25s
7 ~49s

That's not a curve, that's a wall between 4 and 5. Eight times slower for a file size difference most people would not notice. I have not dug into which specific passes turn on at effort 5, and I'd genuinely like to know, but empirically -e 4 is the only setting that makes browser-side encoding of large images feel like software rather than a punishment.

For comparison, small images through this path come out roughly 10x faster than jSquash at effort 7. The fix for the crash turned out to also be the fix for the speed.

Feeding it pixels

Here's a trap I walked straight into. I stripped the build down, which means there is no JPEG decoder inside it. So this:

cjxl in.jpg out.jxl --lossless_jpeg=0
Enter fullscreen mode Exit fullscreen mode

fails with Getting pixel data failed. It can't read the JPEG.

The fix is to let the browser do what browsers are already extremely good at — decode the image to a canvas — and hand cjxl raw pixels as a netpbm file. PPM for RGB, PAM when there's an alpha channel:

// no alpha
const header = `P6\n${w} ${h}\n255\n`;   // + RGB bytes

// with alpha
const header = `P7\nWIDTH ${w}\nHEIGHT ${h}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n`;  // + RGBA bytes
Enter fullscreen mode Exit fullscreen mode

Then:

await loadScript("/assets/vendor/jxl-enc/cjxl.js");
const m = await createCjxl({
  noInitialRun: true,
  locateFile: p => "/assets/vendor/jxl-enc/" + p
});
m.FS.writeFile("in.ppm", ppmBytes);
m.callMain(["in.ppm", "out.jxl", "--num_threads", "0", "-q", "90", "-e", "4"]);
const jxl = m.FS.readFile("out.jxl");
Enter fullscreen mode Exit fullscreen mode

--num_threads 0 is not optional. A build made without thread support aborts if you don't pass it explicitly.

Also: instantiate a fresh module per file. Once an emscripten module aborts, it stays dead — the cached instance will keep failing until you reload the page, and you will spend an hour convinced your input is malformed.

The path that was fine all along

None of the above applies to lossless JPEG transcoding, which is the genuinely great trick JPEG XL has and which almost nobody knows about.

JxlEncoderAddJPEGFrame doesn't decode your JPEG and re-encode it. It repacks the existing coefficients with better entropy coding. You get a file 10-20% smaller, and decoding it reconstructs the original JPEG bit for bit. Not "visually identical" — the same bytes.

m.callMain(["in.jpg", "out.jxl", "--num_threads", "0", "--lossless_jpeg=1"]);
Enter fullscreen mode Exit fullscreen mode

I verified it the boring way: transcode with cjxl, reconstruct with djxl, compare sha256 of original and reconstruction. Identical.

That path never had the memory problem, because it never touches pixels. 22.4MP goes through in about 0.6 seconds.

If you have a photo archive sitting in JPEG, this is close to free space. Nothing is lost and it's reversible.

Where it ended up

Everything runs client-side. Files never leave the browser, which for a tool people point at their own photos seemed like the only defensible design.

The viewer is here if it's useful to you: https://jpegxlconvert.com/en/jxl-viewer/

Drop a .jxl in, it renders. It'll also convert to PNG or JPG if you need something the rest of your OS understands.

If you know why effort 5 falls off that cliff, I'd like to hear it.

Top comments (0)