R·ex / Zeng


MUGer, hacker, developer, amateur UI designer, punster, Japanese learner.

Use OpenResty to Optimise Image Size Painlessly (AVIF / WebP)

The Image Size Optimization Plan

Two and a half years ago, when I first learned that WebP could effectively reduce the size of images, I had a moment of the impulse to "replace all resources in the website with WebP", but when I opened CanIUse, I found that Safari did not support it for a long time, and there were too many places in the website that involved images, it was not available to modify the code.

At that time, I searched for some information, it was about letting Nginx provide different formats of files according to the Accept header provided by the browser. This method does exist, however, I was too lazy to write a command to convert all images to another format, and I thought it was too troublesome to provide two versions of all images in the future! So this plan was shelved.

Later, I had more contact with OpenResty, which is an Nginx integrated with Lua, and we can use Lua scripts to easily do some extensions. I also figured out how to use Lua FFI to call OpenCC API to painlessly support the traditional Chinese version of the website. Since FFI can be supported, then if the browser supports WebP, call a cwebp command to convert the image format and return it, isn't it easier to be implemented? However, my server is completely Dockerized, OpenResty uses the ready-made hustshawn/openresty-opencc-docker, if I want to install cwebp in it, I need to modify the Dockerfile, so this plan was shelved by lazy me again.

Until a few days ago, I accidentally saw an article like this: AVIF has landed, which mentioned a new image format: AVIF, the article provided many examples, it seems that the compression rate and effect are better than WebP. This aroused my interest, I wanted to try to provide AVIF images for browsers that support AVIF.

Of course, since the browser support rate of AVIF is too low (it is said that it will rise soon), I still need to provide a WebP conversion.

So the plan officially begins!

Package AVIF and WebP Converters into Docker

Previously, in order to upgrade the OpenCC version, I forked the image of hustshawn: RexSkz/openresty-opencc-docker, I can just modify this repo. What I need to do is to package the AVIF and WebP related programs into it.

For WebP, it's very simple to just use cwebp. The OpenResty image I used is based on Alpine, so I need to find a way to install cwebp under Alpine. Search cwebp in Alpine Package, it is found that libwebp-tools needs to be installed, so I added a line in the Dockerfile:

RUN apk add libwebp-tools

As for the AVIF part, I found a project called Kagami/go-avif. This project provides a binary executable file for the Linux environment, I can directly use curl to download it into the image during the build, but I also need to install libaom-dev. I searched and did not find the relevant package, so I could only Google the keywords alpine libaom-dev, and found such a website, but there is only Debian data inside, no Alpine...

For those who are familiar with Linux, you may know that the most core part of libaom-dev should be aom, the previous lib indicates that it is a library, and -dev indicates that it will bring some necessary C language header files. So I searched aom in the website search box, and found a package called aom-dev, which should meet the requirements, but at least Alpine 3.12 is required, so I had to upgrade the OpenResty image version. The final part related to AVIF is as follows:

FROM openresty/openresty:1.17.8.2-5-alpine

ARG AOM_VERSION="v1.0.0"

# Alpine has no curl, this also needs to be installed by myself
RUN apk add aom-dev curl \
    && curl -L https://github.com/Kagami/go-avif/releases/download/${GO_AVIF_VERSION}/avif-linux-x64 > /usr/bin/avif \
    && chmod +x /usr/bin/avif \
    && rm -rf /var/cache/apk/*

Push the Dockerfile to GitHub, triggering the automatic build of Docker Hub. After the build is completed, I entered these two commands locally:

docker pull rexskz/openresty-opencc-docker
docker-compose -f local.yaml up -d nginx

Then I went to the Shell in Portainer to see that the two commands cwebp and avif can be used, and files can be converted.

Use OpenResty to Convert and Return Images

Since I am not so familiar with Lua and OpenResty, I still have to refer to the articles written by the big guys on the Internet about configuring WebP in OpenResty, such as this one. The key code is as follows:

-- The author assumes that only when we visit xxx.jpg.webp
-- will it return the WebP format
local newFile = ngx.var.request_filename
local originalFile = newFile:sub(1, #newFile - 5)

-- If the source file does not exist, directly report 404
if not fileExists(originalFile) then
    ngx.exit(404)
    return
end

-- Convert the format and generate a new file
os.execute("cwebp -q 75 " .. originalFile .. " -o " .. newFile)

-- If the conversion is successful, return the new file, otherwise report 404
if not fileExists(newFile) then
    ngx.exit(404)
    return
end
ngx.exec(ngx.var.uri)

I made some changes, encapsulated a few more functions, so that it supports AVIF, and will not convert again when there are already conversion results:

-- Only when there is image/avif in Accept will it be converted
if string.find(ngx.req.get_headers()["accept"], "image/avif") ~= nil then
    local newFile = originalFile .. ".converted.avif"
    -- First try to serve the new file, if not exists, then convert
    if not tryServeFile(newFile, "image/avif") then
        os.execute("avif -e " .. originalFile .. " -o " .. newFile .. " --best -q 12");
        serveFileOr404(newFile, "image/avif")
    end
end
-- The configuration of WebP is similar, so I won't repeat it here

Finally, I also added *.converted.avif and *.converted.webp to .gitignore, it's perfect!

How is the Effect of Conversion?

I opened the website locally and tried it, and found that a banner image took more than ten seconds, most of the time was spent on conversion... I am using an Alienware!

What the hell?

It doesn't matter, I can trigger automatic conversion every time I upload an image, and then make the page public. So how much is the compression rate of AVIF?

After I ls the directory, I found that: for JPG format like Banner, the compression rate is still quite high, it can almost losslessly compress half of the size based on 75% before; but for PNG files, since I need to ensure that the image is not distorted, I chose lossless compression, and the result is that a 1.2 KB icon was compressed to 2 KB...

What the hell?

Does it mean that the lossless compression rate of AVIF is not as good as imagined? I searched again and found a Google spreadsheet, which is a comparison of lossless compression between WebP and AVIF. The data shows that in more than half of the cases, the lossless compression of AVIF will increase the size of the image, and often can reach two or three times!

Forget it, I won't consider converting PNG to AVIF for lossless PNG, it's slow and large.

The Final Effect and Code

After deploying it to the server, I found that it always prompts 404. I looked at the Nginx log and found that due to the permission issue of Docker, the generated image cannot be written to the target path. After fixing the permission issue, the image finally appeared.

You can see that the type of the image has become webp and avif

Since it has been debugged, let's release it to benefit the public. However, it is written redundantly and not optimized.

location ~ \.(jpe?g|png|gif)$ {
    # convert using lua code
    content_by_lua_file /etc/nginx/conf.d/image-convert.lua;
}
function tryServeFile(name, contentType)
    if fileExists(name) then
        local f = io.open(name, "rb")
        local content = f:read("*all")
        f:close()
        if contentType ~= "" then
            ngx.header["Content-Type"] = contentType
        end
        ngx.print(content)
        return true
    end
    return false
end

function serveFileOr404(name, contentType)
    if not tryServeFile(name, contentType) then
        ngx.exit(404)
    end
end

-- According to https://docs.google.com/spreadsheets/d/1TE5iLE08oV90EqOmFHnzBLwiPtQSs1XAvI3QfoMgKQM/edit#gid=0
-- not all formats should be converted to avif because it may have larger file size

if string.find(originalFile, ".png") ~= nil then
    -- for PNG (lossless) we only try to use lossless webp
    if string.find(ngx.req.get_headers()["accept"], "image/webp") ~= nil then
        local newFile = originalFile .. ".converted.webp"
        if not tryServeFile(newFile, "image/webp") then
            os.execute("cwebp -q 100 -lossless " .. originalFile .. " -o " .. newFile);
            serveFileOr404(newFile, "image/webp")
        end
    else
        serveFileOr404(originalFile, "")
    end
else
    -- for other formats, we first try to use avif, then webp
    if string.find(ngx.req.get_headers()["accept"], "image/avif") ~= nil then
        local newFile = originalFile .. ".converted.avif"
        if not tryServeFile(newFile, "image/avif") then
            os.execute("avif -e " .. originalFile .. " -o " .. newFile .. " --best -q 12");
            serveFileOr404(newFile, "image/avif")
        end
    elseif string.find(ngx.req.get_headers()["accept"], "image/webp") ~= nil then
        local newFile = originalFile .. ".converted.webp"
        if not tryServeFile(newFile, "image/webp") then
            os.execute("cwebp -q 80 " .. originalFile .. " -o " .. newFile);
            serveFileOr404(newFile, "image/webp")
        end
    else
        serveFileOr404(originalFile, "")
    end
end

References

Disqus is loading... If it fails to load, please add disqus.com and disquscdn.com to your whitelist.

We've been together for

3860 days