Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 5 min read

Safari's canvas can't write WebP: notes from building a browser-only image converter

I'm the developer of MakeQuickWork, a small set of free browser tools. This post is about its Image Converter: what I expected to be a 20-line canvas wrapper, and the parts that weren't. The naive version of an image

Safari's canvas can't write WebP: notes from building a browser-only image converter

I'm the developer of MakeQuickWork, a small set of free browser tools. This post is about its Image Converter: what I expected to be a 20-line canvas wrapper, and the parts that weren't.

The naive version of an image converter in the browser fits in a tweet: createImageBitmap(file), draw it on a canvas, call canvas.toBlob(cb, "image/webp"). In Chrome that works. Then you open it on an iPhone and the "WebP" file you get back is a PNG with the wrong extension, because Safari's canvas can't encode WebP and silently falls back to PNG.

That was the first of a handful of things that shaped the tool. It converts to JPG, PNG or WebP, opens JPG, PNG, WebP, GIF, BMP, AVIF and HEIC, and everything happens in the tab. No upload: https://makequickwork.com/image/image-converter/

Don't trust the extension

Files saved from the web lie about themselves all the time. A CDN serves WebP to a browser that asked for it, the user hits "Save image as", and they end up with photo.jpg that is actually WebP. So the first thing the pipeline does is read the first bytes and decide for itself:

export function sniff(b: Uint8Array): SourceFormat {
  const ascii = (from: number, to: number) => String.fromCharCode(...b.subarray(from, to));
  if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "jpg";
  if (b[0] === 0x89 && ascii(1, 4) === "PNG") return "png";
  if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") return "webp";
  if (ascii(0, 3) === "GIF") return "gif";
  if (ascii(0, 2) === "BM") return "bmp";
  if (ascii(4, 8) === "ftyp") {
    // ISO-BMFF: major brand plus a list of compatible brands.
    const size = Math.min((b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3], b.length);
    const brands = [ascii(8, 12)];
    for (let i = 16; i + 4 <= size; i += 4) brands.push(ascii(i, i + 4));
    if (brands.some((x) => x === "avif" || x === "avis")) return "avif";
    if (brands.some((x) => ["heic", "heix", "hevc", "hevx", "heim", "heis", "mif1", "msf1"].includes(x))) return "heic";
  }
  return "unknown";
}

AVIF and HEIC are both ISO-BMFF containers, so they share the ftyp box. Checking only the major brand isn't enough: some files put mif1 up front and the interesting brand in the compatible list. AVIF is checked first because an AVIF file can also list mif1.

The sniffed format decides the decode path and a couple of later details, like whether a "transparent areas filled with white" note makes sense.

Decoding: let the browser do it, with one fallback

For almost everything, decoding is:

const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });

imageOrientation: "from-image" applies the EXIF Orientation tag, so phone photos come out upright instead of sideways.

HEIC is the exception. Safari decodes it natively, so I try createImageBitmap first. When that throws (Chrome, Edge, Firefox), the tool loads libheif compiled to WebAssembly and decodes the bytes itself. AVIF has no fallback: current Chrome, Edge, Firefox and Safari decode it, and if one doesn't, the error message says to try a current browser instead of failing silently.

After decoding, everything is drawn onto a canvas. The canvas is capped at about 16 megapixels, because iPhone Safari refuses to allocate canvases above roughly 16.7M pixels. If an image is bigger, it is scaled down to fit and the result row says so (scaled to W Γ— H (browser size limit)), rather than quietly handing back a smaller file.

Encoding: three formats, three paths

PNG is the easy one: canvas.toBlob(cb, "image/png"). Transparency is kept.

JPG has no alpha channel, and if you encode a transparent canvas as JPEG, the transparent pixels typically come out black. So the canvas is first drawn onto a white-filled copy, then encoded with canvas.toBlob(cb, "image/jpeg", quality). If the source actually had transparent pixels, the result row says "transparent areas filled with white". I'd rather tell people than let them discover a white box around their logo later.

WebP never goes through the canvas encoder. It always uses libwebp compiled to WebAssembly, via jSquash:

// Safari's canvas cannot write WebP, so always use the WASM encoder.
const { default: webp } = await import("@jsquash/webp/encode");
const out = await webp(ctx.getImageData(0, 0, w, h), { quality: Math.round(quality * 100) });

I could have used the native encoder where it exists and WASM only on Safari. I chose WASM everywhere so that the same input and the same quality setting give the same file in every browser. Debugging "it's 20 KB bigger on my machine" across two different encoders didn't sound fun.

Quality is a select with three values: Maximum (0.95), High (0.9, default) and Medium (0.8). PNG hides the select, since it's lossless.

On my test images (three small JPGs at High) the WebP versions came out at 41.3 β†’ 24.6 KB, 91.7 β†’ 57.8 KB and 37.9 β†’ 22.1 KB. That's three files, not a benchmark; photos and screenshots behave very differently.

What it deliberately doesn't do

  • Animation. GIF and animated WebP give you the first frame only. Encoding animated WebP is a different project.
  • EXIF. Metadata isn't copied. Orientation is applied to the pixels, and GPS, camera model and date are gone. For a converter that's usually what people want, but it's a choice, so it's listed on the page.
  • Transparency in JPG. It can't exist, hence the white fill.

Batch, ZIP and stale results

You can drop many files at once. One result downloads directly; several are zipped with fflate using level: 0. Images are already compressed, so store-only zipping is basically a memory copy and doesn't stall a phone.

One small rule that took me a while to settle: any change to the options (format or quality) throws away earlier results. Without that, you could convert to WebP, switch to JPG, click Download, and get WebP files. Now clicking the button after a change reconverts everything with the new settings.

Keeping the page light

The tool page ships about 10 KB of JavaScript. The decode/encode module is a dynamic import, and the WebP encoder (and libheif, if needed) only downloads the first time you actually convert something. Someone who lands on the page from search and leaves doesn't pay for a WASM binary.

The site is static (Astro on Cloudflare Pages), so there's no upload endpoint at all. There's also an automated test that processes files in every tool and fails on any POST or any third-party request other than Google Analytics (used for anonymous page counts; the test also fails if an analytics event contains a file name). You do need a connection to open the page and fetch the code.

If you've hit other browser quirks with canvas encoding, or have an opinion on "same encoder everywhere" vs "native where possible", I'd like to hear it in the comments.

https://makequickwork.com/image/image-converter/

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.