Better Portable Graphics (BPG) on the web with WebAssembly (WASM) and ServiceWorker
I have been playing around with Fabrice Bellard's BPG image format + encoder/decoder for fun or as an experiment. I don't expect it to be very useful any time soon outside of being a curiosity; anyone (in thier right mind) who's building an actual web site for people to use would probably use .png
, .jpeg
, or .webp
instead.
libbpg is a C++ library that uses parts from the HEVC / H.265 video encoding to achieve better compression for images than any of the existing formats on the web today, like .png
s, .jpeg
s, or .webp
s. It's actually significantly better, yielding file sizes that are about half as big as JPEG for a similar quality. For transparent images or animations that don't have to be pixel perfect, I think it's even better than twice as good as PNG/WebP/Gif. The Makefile
in the project leverages the emscripten compiler to compile the C++ code to either asm.js
or WebAssembly (WASM).
The difference is especially pronounced when you crank the quality down a lot to produce very small files:
The most recent version of BPG, 0.9.8 was released in April 2018, over 3 years ago. That version uses asm.js
for the HTML demo, but since then emscripten has changed its default output to WebAssembly, so when I decided to try to compile it myself, I was challeged to get it to work with WASM. This wasn't so hard, I simply had to install the emsdk
, tweak a couple small things in the Makefile
, and then slightly adjust the wrapper script because of the way the WASM is loaded asychronously, while the original asm.js was synchronous.
The original HTML demo essentialy scans the document for <img>
elements that have a src
attribute ending in .bpg
, and then replaces them with <canvas>
elements, downloads the bpg
files, decodes them, and then renders them to the canvases. This works fine for most use-cases, but if the user wants to save the image to thier desktop, it's a bit cumbersome. They would have to take a screenshot. Also, it doesn't work very well with various other ways that images can be used in HTML, for example background images or custom border images in CSS.
If I wanted to do the same for a CSS border image, I would have to draw the bpg to a canvas like before, then use canvas.toDataURL()
to generate a base64 version of the resulting PNG image, then inject additonal CSS into the page to set the border-image
correctly. Or I would have to implement the features I need (of css background and border-image) myself in the <canvas>
element using JavaScript.
While this is theoretically possible, it doesn't seem extremely practical to me. Heh. Talking about practicality in a post about BPG.
🤔
If only there were some standardized way to get in between the web browser and the server, but on the client-side of the network... Then I could do the BPG -> PNG conversion right there. The web browser can make requests for image files from all manner of places, <img>
tags, CSS properties, the user right-clicking the image and chosing "open in new tab", etc. There are maybe even other ones I'm forgetting, and I don't feel like keeping track of them all / polyfilling each one individually.
Of course I'm leading up to something... There is a standardized way to do this! It's called ServiceWorker, and I've talked about it multiple times on this blog:
- ServiceWorker + WebRTC... The p2p web solution I have been looking for!?
- How to Get Started with ServiceWorker
In the past, I used ServiceWorker to enable my Password Manager web application to continue working when either the client or server are offline.
This time I defined a ServiceWorker that intercepts HTTP requests and scans them for .bpg
images. If it finds one, it will send a message to the HTML page requesting that the bpg-encoded bytes from the HTTP response should be decoded, pasted into a <canvas>
element, and then exported as a png
. Finally, it modifies the HTTP response to have Content-Type: image/png
, and to serve the PNG bytes that were exported from the canvas.
I can see this happening in the network tab of the Chromium developer tools. (Firefox still doesn't appear to have the best ServiceWorker debugging support).
The first zonnebloem.bpg request was initiated by the <img>
tag on the page, and handled by the ServiceWorker. After that, the request at the bottom is the corresponeding fetch()
request made by the ServiceWorker itself. We can see that 24kB of .bpg
data was downloaded from the internet, while image/png
response from the ServiceWorker running locally in the browser wieghs in at a hefty 356kB.
Because the ServiceWorker catches any and all HTTP requests from the browser, when the user chooses "Open Image In New Tab...", they will see .bpg
in the URL bar, while being greeted by a PNG they can actually see in the middle of thier browser window. Cool!
However, with my current design, this only works if the image is cached or if the original HTML page is still open. Once the user closes the HTML page that originally registered the ServiceWorker, they will recieve an error if they attempt to browse to any never-before-seen .bpg
URLs. There's probably a way around this, for example, if the C++ code could also encode the PNG. That way the decoding/re-encoding process could live entirely within the worker, eliminating the communication between the worker and the HTML page. I haven't bothered with that yet, though.
--> Live .bpg ServiceWorker Demo <--
How viable is this? Not very, unless you are making a thicc app. But it's not because of code filesize like you might think.
If I'm doing this just to shave a few kilobytes off of my images, the code which does it had better be small, and fortunately, it is. Well, sort of. The bpg code including the index.html and the serviceworker is 90kB uncompressed, and 38kB after being gzipped. This is a mild improvement from the old asm.js
version which was 204kB uncompressed and 61kB gzipped. I would have to serve at least one fullscreen image or one large transparent image to be able to save that much space by using .bpg
instead of .jpeg
or .webp
. Also, I haven't researched how much extra CPU time is required by this process, especially on older cell phones. It's possible that even a bad 3G cell phone connection would still display a jpeg faster simply because the ServiceWorker / BPG decoding process takes longer on the CPU.
Right now the biggest problem might be the way that lots of memory gets allocated to encode & shuffle these PNGs around, especially for large images (the very same images that BPG was supposed to help us access faster... 🤦). I'm not entirely sure, but I do notice that the images take longer to load on my Moto G7 than they do on a new iPhone or a desktop computer.
However, I'd say the final nail in the coffin of this technique's widespread-use-viability has to be the inherent first-page-load lag which was built into serviceworker's design. Unfortunately, web pages simply cannot leverage ServiceWorker on thier first page load. So if your web application REQUIRES ServiceWorker in order to function, it will have to refresh itself after the ServiceWorker is registered. This process takes time, and it's unavoidable.
If you're developing a fully fledged web application that maybe already uses service worker or has some sort of "boot process" which the user is expecting, doing this would be slightly less insane. Users already expect highly interactive "heavy" web apps to take a second to start, as long as they're snappy afterwards.