I read a post this week about a photo editor that runs entirely in the browser. The author built APIC Web, a full editor with background removal and colour grading, all in one HTML file, and your photos stay on your device. My first thought was that it had to be a trick. A photo editor is the kind of thing that needs a server, a queue, a GPU somewhere. So I built the smallest version I could, to find out where the trick was.
There was no trick. The whole thing is three browser APIs I had mostly ignored. The File API loads a photo from your disk, the canvas draws it and edits the pixels, and toBlob turns it back into a file you can download. Nothing uploads anywhere. I kept waiting for the part where I needed a backend, and it did not show up.
The three APIs
The flow is short enough to fit in one function. An input of type file hands you a File, createObjectURL turns it into a URL the browser can load as an image, drawImage puts it on a canvas, and toBlob encodes the canvas back into a file. That is the whole editor, minus the filter, and the filter is a loop over pixels.
filter.js
function filterPhoto(file, canvas) { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); const data = ctx.getImageData(0, 0, canvas.width, canvas.height); for (let i = 0; i < data.data.length; i += 4) { const r = data.data[i], g = data.data[i + 1], b = data.data[i + 2]; const gray = 0.299 * r + 0.587 * g + 0.114 * b; data.data[i] = gray; data.data[i + 1] = gray; data.data[i + 2] = gray; } ctx.putImageData(data, 0, 0); canvas.toBlob((blob) => { // blob is your new file, download it or preview it }, 'image/png'); }; img.src = url; } Why I did the pixels by hand
The obvious way to filter is ctx.filter, one line, same values as CSS. I almost used it, then I checked the compat table. It works in Chrome and Firefox, but it does not work in Safari, on desktop or iPhone. The property exists in the spec, the browser just ignores it, so the filter buttons would have done nothing on my phone, which is the device I actually test on. Doing the pixels by hand with getImageData works everywhere, and for a filter this small the loop is fast enough that you do not feel it.
The JPEG surprise
The part that actually caught me was the export. I exported my sample as a JPEG and the transparent corners came out black. Not an error, not a warning, just black. JPEG has no alpha channel, so there is nowhere for transparency to go, and the canvas spec says a canvas saved to a format without alpha gets composited onto a solid black background. Transparent becomes black, and half-transparent edges darken toward black. The fix is one line before you draw: fill the canvas with a background first, white or whatever you want, or export PNG and keep the transparency.
export.js
function exportAsJpeg(canvas, img) { const ctx = canvas.getContext('2d'); // JPEG has no alpha channel, so paint a background first ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0); canvas.toBlob((blob) => { // the corners come out white now, not black }, 'image/jpeg', 0.92); } Live: filter a photo, export it, nothing leaves your laptop
Try it live on nayemalam.com.
The demo above is the whole thing. The sample has a transparent background on purpose, so flip the format to JPEG and watch the corners go black in the exported preview. Upload your own photo and it works the same way, the file stays on your laptop the entire time.
I went in expecting to find the catch, the one thing that forces you back to a server. I did not find it. The browser decodes the image, edits the pixels, and encodes the file, and the only thing I had to learn was which format keeps what. That is a lot of photo editor for zero infrastructure, and it changed what I reach for first when something looks like it needs a backend.
Originally posted on nayemalam.com/blog.