If youāre going to be accepting and serving images in your webserver, itās a good idea to make sure they donāt take up HUGE amounts of space. Storage doesnāt grow on trees you know. A portion of what makes images take up a lot of space is its quality, images that look better take more space than those that look š©.
File formats are another portion of that puzzle. Not all formats were created the same, something like GIF is an incredibly wasteful format as it stores each individual frame separately whereas something like MP4 stores the transitions between frames which makes it vastly more efficient for representing animation.
WebP is one of those file formats that allow for lossy compression in a way that (on average) makes high resolution images look better than what JPEG provides.
š Virgin PNG
7.1MBThat's crazy. Nobody wants to download this file and you don't want to serve it either.
š Chad WebP
433KBNow this is the good stuff. Look at how much smaller that is. Imagine not using webp in 2023.
Can you see the difference? I sure canāt. Try opening them in a new tab and zoom in, itās still not noticeable. The conversion from Png WebP here is saving us that many bytes which makes the image load faster. Thatās a 6x+ reduction in file size for free.
There are some implications of using improved file formats of course. A browser first has to be able to support the file format that youāre using so outdated browsers like Internet Explorer and Safari donāt support it fully.
Getting Started
Letās get started with converting images in Javascript by installing the basic dependencies. Make sure you use the npm
version of these commands if youāre not on yarn.
Shell
$ yarn add fluent-ffmpeg
Weāre going to be using ffmpeg to do our image conversions. If youāre not familiar with ffmpeg, itās the god tool for working with any kind of media. Images, sound, videos, gifs, subtitles. Itāll take anything and modify it in any way you want. Hereās a simple example that works on the commandline.
Shell
$ ffmpeg -i image.png image.jpeg
Nothing too fancy here, weāre simply setting image.png
as the input file and turning it into a image.jpeg
. Ffmpeg is able to infer that we want a Jpeg output We can also pass in any kind of option we want. If weāre converting to WebP, we can control the quality of the resulting file through these options.
Shell
$ ffmpeg -i image.png -quality 50 image.webp
This will produce a fairly low quality but an amazingly small webp version of our image. The default ffmpeg value for webp images is 75 which I find to be adequate but you can feel free to play around with it as you like.
CLI programs can be a bit of a pain to run so letās jump on fluent-ffmpeg which is a more convenient version of the api.
lib/image.tsTypescript
1import ffmpeg from "fluent-ffmpeg"2import * as fs from "fs"3import { Readable } from "stream"45function convertImage(imagePath: string, outputName: string) {6 ffmpeg().input(image).saveToFile(outputName)7}89convertImage("./image.png", "./image.webp")
This produces the same output as the one above, but itās not very practical, letās look at an express example where a user uploads an image using multipart/form-data
.
Typescript
1import express from "express"2import multer from "multer"3import fs from "fs/promises"4import { convertImage } from "./lib/image"56const upload = multer({ dest: "uploads/" })78const app = express()910app.post("/profile", upload.single("avatar"), async (req, res, next) => {11 convertImage(req.file.path, "./image.webp")12 // image.webp is now available on disk13})
Thatās it, thatās all you need to convert images to webp, wasnāt so problematic was it? Except thereās a problem, this method of doing conversions is extremely inefficient. Weāre writing the original file to disk first even though we just end up reading it again right afterwards. Thankfully multer allows only saving things in memory without writing to disk with the following.
const upload = multer({ storage: multer.memoryStorage() })
There are basically no other multipart parsers that do this. I spent many hours looking and multer was the only one. Kinda crazy...
Images, which are just binary data in memory, are represented with the Buffer
data type and ffmpeg by default takes a file path. Being a CLI program, we can pipe a stream of bytes into it in the CLI like this.
$ cat image.png | ffmpeg -i pipe:0 image.webp
In english terms thatās equivalent to
Take the contents of image.png, pipe it across this program which takes its input from
pipe:0
and convert it to image.webp.
pipe:0
here being stdout that we pushed the contents of image.png into. If youāre not familiar with unix pipes definitely check look into them.
In fluent-ffmpeg, doing this kind of piping requires the input to be a ReadableStream
. The conversion between Buffers and Streams is a bit hacky and it involves manually disabling some internal methods but itās doable and works just fine.
lib/image.tsTypescript
1import { Readable } from "stream"23function convertImage(image: Readable, outputName: string) {4 ffmpeg().input(image).saveToFile(outputName)5}67export function bufferToStream(buffer: Buffer): Readable {8 const readable = new Readable()9 readable._read = () => {}10 readable.push(buffer)11 readable.push(null)12 return readable13}
this now lets us patch the previous function like so.
Typescript
1import express from "express"2import multer from "multer"3import { bufferToStream, convertImage } from "./lib/image"45const upload = multer({ storage: multer.memoryStorage() })67const app = express()89app.post("/profile", upload.single("avatar"), async (req, res, next) => {10 const stream = bufferToStream(req.file.buffer)11 convertImage(stream, "./image.webp")12 // image.webp is now available on disk13})
This method works pretty well but we still have the same issue on the other end. Weāre saving an image to disk but often we want to do something like upload it to S3 or save it somewhere else and writing the image to a file ends up being a pointless operation that takes time and resources. For this example weāre going to pretend we want an output we can pass along to the AWS sdk to upload on S3 instead of saving on disk. https://wasabi.com is a great service for this.
Thankfully, ffmpeg allows outputting a stream just as it allows for a stream input. To do that we will use a PassThrough
stream which, as the name states, just passes the stream through. We use this to capture the output of the conversion.
lib/image.tsTypescript
1import { Readable, PassThrough } from "stream"23function convertImage(image: Readable, outputFormat: string) {4 const passthrough = new PassThrough()5 ffmpeg()6 .input(image)7 .outputFormat(outputFormat)8 .stream(passthrough, { end: true })9 return passthrough10}
Now that weāre passing a stream to ffmpeg it can no longer infer what filetype it needs to turn the image into so we need to explicitly tell it that weāre looking to convert to a specific format.
For testing purposes, we can try writing this to a file manually.
Typescript
1app.post("/profile", upload.single("avatar"), (req, res, next) => {2 const stream = bufferToStream(req.file.buffer)3 const output = convertImage(stream, "webp")4 output.pipe(fs.createWriteStream("./image.webp"))5})
Aaand it work- waitā¦ what the hell is this?
The file is corrupted?? What on Earth could be going on?
Debugging stream issues
The exact same command we tried worked with a file, why is it not working with a stream as opposed to a file target? To start debugging this, we first need to understand how the webp spec works and how ffmpeg interacts with streams.
The webp spec says that images need to have the block size of the file at the beginning of the file like so.
A working webp image will have a file size signature where the image size is between the first 4th and 7th bytes.
However, when we pipe the data directly out of ffmpeg without writing to disk using this command (which is the equivalent of the original problematic js code).
$ cat image.png | ffmpeg -i pipe:0 -f webp pipe:1 > output.webp
The exact same file looks like thisā¦ why?
Essentially, ffmpeg doesnāt know the size of the image as itās going over it so it uses 4 null bytes 00 00 00 00
as a placeholder and plans to seek back to the beginning of the image when itās done to replace it with the size of the image it just read. Except when piping to stdout, ffmpeg is unable to seek back. Try to see if you can spot where the missing 2e 8a 03 00
bytes might have gone instead of the header.
Iām not sure why, but instead of warning you that piping a webp output to stdout will not work, ffmpeg decides to go ahead and just do it anyways and adds the file size at the end of the image. This will likely be fixed in the future by failing with a warning, but as of version 4.2.4, ffmpeg does weird things with webp outputs. (The same thing applies for other file formats that have block sizes in the header like FLAC files).
One interesting caveat to this is if the image is small enough for ffmpeg to read in one go without separating into multiple chunks (less than a handful KBs), it doesnāt have to seek back to the beginning and will properly include the file size in the header from the start.
Putting it Together
This bug isnāt the end of the world for us though. We can fix it by simply moving the last 4 bytes of the image into the 4-7 byte position. Javascript has very efficient methods on typed arrays and buffers that let us move data around without much overhead called copyWithin
and slice
. copyWithin
is similar to the performant memcpy and memmove in C (it even has the same gross type signature) and slice
returns a view without copying anything much like the &str
type in Rust. Definitely not at all confusing with Array.prototype.slice
which is often used to explicitly copy arrays. Good job Javascript.
One problem we run into when implementing this fix is that streams are not directly editable and we have to consume the entire stream first and then concat the chunks into a single buffer before we can start picking apart the bytes.
lib/image.tsTypescript
1import { Readable, PassThrough } from "stream"23function convertImage(image: Readable, outputFormat: string) {4 return new Promise((resolve, reject) => {5 const chunks: Buffer[] = []6 const passthrough = new PassThrough()7 ffmpeg()8 .input(image)9 .outputFormat(outputFormat)10 .on("error", reject)11 .stream(passthrough, { end: true })12 passthrough.on("data", data => chunks.push(data))13 passthrough.on("error", reject)14 passthrough.on("end", () => {15 const originalImage = Buffer.concat(chunks)16 const editedImage = originalImage17 // copy everything after the last 4 bytes into the 4th position18 .copyWithin(4, -4)19 // trim off the extra last 4 bytes ffmpeg added20 .slice(0, -4)21 return resolve(editedImage)22 })23 })24}
And it worked!
Now we can publish to s3 easily with
Typescript
1import express from "express"2import multer from "multer"3import { S3, Endpoint } from "aws-sdk"4import { credentials } from "./config"5import { bufferToStream, convertImage } from "./lib/image"67const upload = multer({ storage: multer.memoryStorage() })8const app = express()910export const wasabi = new S3({11 endpoint: new Endpoint("s3.wasabisys.com"),12 credentials,13 s3ForcePathStyle: true,14})1516app.post("/profile", upload.single("avatar"), async (req, res, next) => {17 const stream = bufferToStream(req.file.buffer)18 const output = await convertImage(stream, "webp")19 await wasabi20 .putObject({21 Bucket: process.env.MY_BUCKET,22 ContentType: "image/webp",23 Key: "test.webp",24 Data: output,25 })26 .promise()27})
And thatās all! You have an optimized webp optimized image uploader for your app. You might want to also convert to some fallback formats like jpeg if you want to support older browsers just in case.