Table of Contents
Backstory
I recently came upon a need to optimize images client-side prior to uploading them to a back end (in this case AWS S3). Ordinarily, this would be done on the back end (i.e. your front end sends a request containing an unoptimized image to the back end, which then optimizes that image before saving it), but for this project I really wanted to do this on the client.
Code
All the code for this can be found here.
Getting to work
The canvas element
Overview
It turns out that the best way (in this case) to create an image with javascript is by using a canvas element! How do we do that? By creating a 2d context, drawing our image in it, then calling the toBlob
method.
Code
For this particular project, I am working with images as File Objects, obtained, for example, by using a function such as
(e) => e.target.files[0];
on an HTML file input
element's onchange
event.
Because of this, let's write the helper function readPhoto
, which creates and returns a canvas element containing the image given to it. The code for this function is as follows:
const readPhoto = async (photo) => {
const canvas = document.createElement('canvas');
const img = document.createElement('img');
// create img element from File object
img.src = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.readAsDataURL(photo);
});
await new Promise((resolve) => {
img.onload = resolve;
});
// draw image in canvas element
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
return canvas;
};
Let's break down what this code is doing.
First we create two HTML elements, an img
and a canvas
.
Why do we need the img
? Because the drawImage method we will be using expects a CanvasImageSource as one of its parameters, and an HTMLImageElement is going to be the most convenient for us to create.
Next we read the photo into the img
element using the readAsDataURL method and a cute little promisify trick.
After that, we make sure we wait for the img
to load using the promisify trick again with the following:
await new Promise((resolve) => {
img.onload = resolve;
});
Once we have our photo into img
, and img
has loaded, we draw it onto our canvas and return.
// draw image in canvas element
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
return canvas;
Overall Structure
Overview
Okay, so we now know how to get a File into a canvas. Great! Now, let's talk about what we're going to do with it by looking at the function optimizePhoto
, the canonical main
of our little helper file.
Basically, what we're doing is taking our image, shrinking it to a maximum width that is set via an environment variable (or really any way you'd like to set this!), and then returning it as a Blob.
To add a little bit of complexity, I have found that it's best to first keep shrinking our image in half until we need to use bilinear interpolation (aka scaling by a factor not divisible by 2) to finish the job. This is a very quick and easy thing to do, so we'll go ahead and add it to this function.
Code
The function looks like this:
export default async (photo) => {
let canvas = await readPhoto(photo);
while (canvas.width >= 2 * MAX_WIDTH) {
canvas = scaleCanvas(canvas, .5);
}
if (canvas.width > MAX_WIDTH) {
canvas = scaleCanvas(canvas, MAX_WIDTH / canvas.width);
}
return new Promise((resolve) => {
canvas.toBlob(resolve, 'image/jpeg', QUALITY);
});
};
Nothing too crazy (besides maybe the use of our little promisify trick), but we're going to need to talk about one new function that this function depends on: scaleCanvas
.
Scaling a canvas
Overview
Scaling a canvas actually turns out to be pretty simple, as we can reuse that drawImage method, just using a canvas
as input instead of an img
as input.
To do this, we simply make a new canvas
, set its width and height to our desired dimensions, then call drawImage
with the new width/height.
Code
The code for this is as follows:
const scaleCanvas = (canvas, scale) => {
const scaledCanvas = document.createElement('canvas');
scaledCanvas.width = canvas.width * scale;
scaledCanvas.height = canvas.height * scale;
scaledCanvas
.getContext('2d')
.drawImage(canvas, 0, 0, scaledCanvas.width, scaledCanvas.height);
return scaledCanvas;
};
Conclusion
And that is it! Now we can simply pass an image to optimizePhoto
and get a resized photo.
For example, assuming the following HTML
<input id="file-input" type="file" multiple />
We can generate upload resized photos with the following javascript:
const input = document.getElementById('file-input');
const input.onChange = (e) => {
e.target.files.forEach(async (photo) => {
const resizedPhoto = await optimizePhoto(photo);
await uploadPhoto(resizedPhoto); // or do whatever
});
}
Please Note
The algorithm used to resize photos by a factor other than 2 is not necessarily bilinear interpolation. At least as far as I've been able to find. From my own personal testing, it seems as though Firefox and Chrome will both user bilinear interpolation, which looks just fine in most cases. However, it is possible to manually bilinearly interpolate an image, which I may make another post about. If you happen to have a need for it, this also applies to using another scaling algorithm such as nearest neighbor or bicubic interpolation.
Promisify?
I wrote about this cute little trick right here.
Updating callback-style code to use async/await
Taylor Beeston ・ Jun 7 '20 ・ 3 min read
Basically, you create a new Promise that wraps around a function that relies on callbacks, then simply use resolve in the callback to magically 'promisify' that function!
Top comments (0)