Serverless Image Manipulation

.

.

.


What is this doing?

At a high level we accept an uploaded image into an in-memory file blob in the browser, and then post that file as part of a multipart form along with some extra info to a URL with the shape {HOSTNAME}/api/image/{ACTION}.

This URL corresponds to a Go serverless function running on Vercel. That function processes the image in some way and builds a response with the processed image; which we use to replace the in-memory file blob client-side.


How is it doing it?

When an image is uploaded we collect the image into a file blob, then use URL.createObjectURL() to display a preview of the working image:

function setPreviewSrc(img: Blob) { // imgEl refers to a bound <img /> element imgEl.src = URL.createObjectURL(img); imgEl.onload = () => { URL.revokeObjectURL(imgEl.src); // free memory }; }

After the image is uploaded we show a preview, as well as a form that contains several inputs as described here. A select sets the action to be applied to the image, and also toggles additional inputs depending on the option selected. We use a hidden input to send the content type (collected on upload) along with the form data:

{#if showPreview} <!-- PREVIEW --> <form bind:this={form} on:submit|preventDefault={handleSubmit} class="..."> <input hidden name="content_type" bind:value={contentType} /> <div> <label for="formAction" class="..."> Select an action </label> <select id="formAction" name="action" bind:value={action} class="..."> {#each Object.entries(actions) as [value, name]} <option {value}>{name}</option> {/each} </select> </div> {#if action == "blur"} <!-- BLUR SLIDER --> {:else if action == "pixelate"} <!-- PIXELATE SLIDER --> {/if} ... </form> {/if}

We then use the Javascript Fetch API to POST to the relevant transform API endpoint — our serverless function:

// build form const fData = new FormData(form); fData.set("image", image); const gRes = await fetch(`http://localhost:3000/api/image/${action}`, { method: "POST", body: fData, });

In the Go function we extract the image from the multipart form and then decode it based on the content type:

// this keeps the whole form in memory, so be careful of space err := r.ParseMultipartForm(32 << 20) // max 32MB if err != nil { w.WriteHeader(http.StatusBadRequest) return } file, _, err := r.FormFile("image") if err != nil { w.WriteHeader(http.StatusBadRequest) return } defer file.Close() contentType := r.PostFormValue("content_type") // util.Decode() is an internal function that contains // a simple switch statement used to decode the image // correctly based on contentType img, err := util.Decode(file, contentType) if err != nil { w.WriteHeader(http.StatusUnsupportedMediaType) return }

Once we decode the image we perform some processing on it depending on the function being called. We then re-encode the image to a buffer and write it to the response:

processedImg := someProcess(img) outputBuf := new(bytes.Buffer) // like util.Decode(), util.Encode() is an internal function // that contains a simple switch statement used to encode the image err = util.Encode(outputBuf, processedImg, contentType) if err != nil { panic(err) } w.Header().Set("Content-Type", "application/octet-stream") w.Write(outputBuf.Bytes())
* A possible improvement to be made here would be to join the functions into a single image processing function and take the action as another field in the form. This would greatly reduce code repetition, at the cost of an increasingly long and complicated switch statement.

Finally, we retrieve the new binary from the response on the client side and set the preview to use the new URL:

if (gRes.status < 400) { // set image to blob from response image = await gRes.blob(); } setPreviewSrc(image);

Et voilà!

Site source and Go source
© 2023 William Wernert