Chrome, Edge and most sites now hand you WebP or AVIF when you right-click an image and pick "Save image as". Renaming photo.webp to photo.jpg changes the label and nothing else: the bytes are still WebP, and the next print shop, marketplace upload form or old image editor rejects it.
I make PixLasso, a Chrome and Edge extension that saves every image on a page into one ZIP. The feature people asked for first was a right-click entry, "Save image as JPG", that converts on your computer and never uploads anything. This post is about how that conversion runs inside a Manifest V3 extension, and the three things that bit me.
Where the pixels can be decoded
An MV3 background script is a service worker. It has fetch, createImageBitmap and OffscreenCanvas, so decoding and re-encoding an image is possible there. Two things are missing, though:
-
URL.createObjectURLdoes not exist in a service worker, andchrome.downloads.downloadwants a URL. Adata:URL works for a small file and falls over for a 40 MB ZIP. - SVG cannot go through
createImageBitmapreliably. It needs an<img>element, which needs a document.
So the work runs in an offscreen document (chrome.offscreen.createDocument with the BLOBS reason). It is a hidden page the worker opens on demand. The worker fetches nothing itself: it sends the job over chrome.runtime.sendMessage, the offscreen page fetches, decodes, converts, zips, creates a blob URL and asks the worker to start the download.
Decode, then draw, then encode
The decode step tries the fast path and falls back to <img> only for SVG:
async function decode(blob, fromType) { if (fromType !== 'svg') { try { return await createImageBitmap(blob); } catch { /* fall through */ } } const url = URL.createObjectURL(blob); try { const img = new Image(); await new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('Image decode timed out')), 15000); img.onload = () => { clearTimeout(t); resolve(); }; img.onerror = () => { clearTimeout(t); reject(new Error('Unsupported or corrupt image')); }; img.src = url; }); return img; } finally { setTimeout(() => URL.revokeObjectURL(url), 1000); } } Note the timeout. An <img> for a broken SVG can sit there forever without firing either event, and img.decode() may never resolve in a document nobody renders.
The conversion is an OffscreenCanvas, a drawImage, and convertToBlob:
const canvas = new OffscreenCanvas(w, h); const ctx = canvas.getContext('2d'); if (toType === 'jpg') { // JPEG has no alpha; without this, transparent pixels turn black ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); } ctx.drawImage(src, 0, 0, w, h); if (src.close) src.close(); // free the ImageBitmap now, not when GC gets around to it const out = await canvas.convertToBlob({ type: 'image/jpeg', quality }); return new Uint8Array(await out.arrayBuffer()); The three things that bit me
Canvas limits. Chrome refuses a canvas over 32,767 px on a side or 268,435,456 px in total. A 20,000 × 15,000 scan from a museum site is under the side limit and over the area limit, and getContext quietly returns a context that draws nothing. The fix is one scale factor applied before the canvas is created:
const scale = Math.min(1, 32767 / Math.max(w, h), Math.sqrt(268435456 / (w * h))); Trust the bytes, not the name. A URL ending in .jpg served with Content-Type: image/webp is common on CDNs, and the other way round happens too. The first bytes decide: RIFF....WEBP is WebP, \xFF\xD8\xFF is JPEG, \x89PNG is PNG, and AVIF is an ISO BMFF box with ftyp at offset 4. Sniff those, then decide whether a conversion is needed at all. If the source is already the wanted format, keep the original bytes: re-encoding a JPEG as JPEG only loses quality.
Messages sent before the offscreen document is ready are lost. createDocument resolves before the page's script has registered its onMessage listener. The offscreen page sends an offscreen-ready message as its last line, and the worker holds jobs until it has seen that message. Without the handshake, the first job after a cold start can disappear, which is the worst kind of bug to reproduce.
Two smaller ones: run jobs one at a time in the offscreen page so a 500-image batch does not hold 500 decoded bitmaps at once, and revoke each blob URL after chrome.downloads reports the download complete, with a 30-minute timer as a safety net in case the worker restarts and never sends the revoke.
Permissions
The whole thing needs offscreen, downloads and contextMenus. Image bytes are fetched from the offscreen page with the page's own cookies where the site allows it, and nothing leaves the browser. That last part is the reason I built it: the best-known "save as type" extension in this category was pulled from the store this year for injecting affiliate links, and the users who lost it deserve something that just converts the file.
PixLasso is on the Chrome Web Store and Edge Add-ons. The right-click conversion is free; the batch download is free up to 30 files and $9.99 once above that. More on the site: https://pixlasso.com/?src=devto
Disclosure: I am the developer. The code above is simplified from the extension's offscreen script; the real one also handles AVIF, square padding for marketplace photos, and ZIP splitting.
