图片体积优化计划

两年半之前,我第一次了解到 WebP 可以有效减少图片体积的时候,有一瞬间产生了“把网站中所有资源替换成 WebP”的冲动,但当我打开 caniuse 的时候,发现 Safari 迟迟不支持,况且网站中涉及到图片的地方实在是太多,修改代码不太现实。

我当时搜了一些资料,是关于让 Nginx 针对浏览器提供的 Accept 头来分别提供不同格式的文件。这个方法确实有,然而我实在是太懒了,以至于写一段命令将所有图片转个格式都不想写,并且想到以后所有图片都要提供两个版本,实在是太麻烦了!于是这个计划就被搁置了。

后来我更多的接触了 OpenResty,这是一个集成了 Lua 的 Nginx,可以利用 Lua 脚本来方便的做一些扩展。我还摸索出了如何利用 Lua FFI 调用 OpenCC API 无痛让网站支持了繁体版本。既然 FFI 都能支持,那么如果发现浏览器支持 WebP,则调用一个 cwebp 命令转换图片格式并返回,不是更好实现了?不过我的服务器已经完全 Docker 化,OpenResty 是使用了现成的 hustshawn/openresty-opencc-docker,如果想安装 cwebp 进去,需要修改 Dockerfile,因此这个计划又被懒惰的我给搁置了。

直到前两天,我无意间看到了这样一篇文章:AVIF has landed,里面提到了一个新的图片格式:AVIF,文章里提供了很多例子,看起来压缩率和效果都 WebP 要好。这激起了我的兴趣,想试一下能否为支持 AVIF 的浏览器提供 AVIF 的图片。

当然,由于 AVIF 的浏览器支持率实在是太低(据说很快会高起来),于是我还是需要顺便提供一个 WebP 的转换。

于是计划正式开始!

将 AVIF 与 WebP 转换器打包进 Docker

之前我为了升级 OpenCC 版本,自己 Fork 了一下 hustshawn 的镜像:RexSkz/openresty-opencc-docker,刚好在这个上面做修改。我要做的就是将 AVIF 和 WebP 相关的程序打包进去。

WebP 很简单,用 cwebp 就可以了,我用的 OpenResty 镜像是基于 Alpine 的,因此需要找到在 Alpine 下面安装 cwebp 的方法。在 Alpine Package 中搜索 cwebp,发现需要安装 libwebp-tools,于是在 Dockerfile 中添加了一句:

RUN apk add libwebp-tools

至于 AVIF 的部分,我找到了一个 Kagami/go-avif 项目。这个项目提供了 Linux 环境的二进制可执行文件,我可以直接用 curl 在 Build 的时候下载到镜像中,但还需要安装 libaom-dev,我搜了一下,并没有发现相关的包,于是只能 Google 了一下 alpine libaom-dev 关键字,发现了 这样一个网站,但里面只有 Debian 的数据,没有 Alpine……

熟悉 Linux 的小伙伴可能会知道,libaom-dev 最核心的部分应当是 aom,之前的 lib 表示是一个库,-dev 表示这会带上一些必要的 C 语言头文件。于是我在网站搜索框里搜索 aom,发现了一个叫 aom-dev 的包,应该是符合要求的,但需要至少 Alpine 3.12,因此我不得不升级了 OpenResty 镜像版本。最终跟 AVIF 相关的部分如下:

FROM openresty/openresty:1.17.8.2-5-alpine

ARG AOM_VERSION="v1.0.0"

# Alpine 是没有 curl 的,这个也要自己安装
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/*

将 Dockerfile 推到 GitHub,触发了 Docker Hub 的自动构建。当构建完成后,我在本地输入了这两条命令:

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

然后我又上 Portainer 的 Shell 中看了一下,cwebpavif 两个命令都已经可以使用,并可以转换文件了。

使用 OpenResty 转换图片并返回

由于我对 Lua 和 OpenResty 还没有那么熟悉,于是还是得参考网上大佬们写的 OpenResty 配置 WebP 的文章,例如 这篇。关键代码如下:

-- 作者假定我们访问 xxx.jpg.webp
-- 才会返回 WebP 格式
local newFile = ngx.var.request_filename
local originalFile = newFile:sub(1, #newFile - 5)

-- 如果源文件不存在,直接报 404
if not fileExists(originalFile) then
    ngx.exit(404)
    return
end

-- 转换格式,生成新文件
os.execute("cwebp -q 75 " .. originalFile .. " -o " .. newFile)

-- 如果转换成功则返回新文件,否则报 404
if not fileExists(newFile) then
    ngx.exit(404)
    return
end
ngx.exec(ngx.var.uri)

我稍加改动,又封装了几个函数,便使它支持了 AVIF,并且在已有转换结果的时候不会重复转换:

-- 当 Accept 中有 image/avif 时才转换
if string.find(ngx.req.get_headers()["accept"], "image/avif") ~= nil then
    local newFile = originalFile .. ".converted.avif"
    -- 首先尝试 serve 新文件,如果没有才会做转换
    if not tryServeFile(newFile, "image/avif") then
        os.execute("avif -e " .. originalFile .. " -o " .. newFile .. " --best -q 12");
        serveFileOr404(newFile, "image/avif")
    end
end
-- WebP 的配置类似,就不重复写了

最后我还不忘把 *.converted.avif*.converted.webp 加入了 .gitignore,简直 Perfect!

转换的效果如何呢?

我打开本地的网站试了一下,发现一张 Banner 图片就等了十几秒,大部分时间都花在转换上了……我可是用的外星人啊!

没关系,大不了之后我每次上传图片,都触发一下自动转换,然后再把页面公开化就好了。那么 AVIF 的压缩率有多少呢?

我去目录下 ls 之后发现:对于 Banner 这种 JPG 格式压缩率还是挺高的,能在之前 75% 的基础上几乎无损的再压缩一半体积;但对于 PNG 文件来说,由于我需要保证图片不失真,因此选择了无损压缩,结果一个 1.2 KB 的图标硬是被压缩成了 2 KB……

难道 AVIF 的无损压缩率其实并没有想象中的好?我又搜了一圈,发现了一个 Google 表格,里面是 WebP 与 AVIF 的无损压缩对比。数据表明,在一半以上的情况下,AVIF 的无损压缩会让图片体积增大,甚至经常能达到两三倍!

得了,我用于无损的 PNG 就不要考虑转换为 AVIF 了,又慢体积还大。

最终的效果与代码

部署到服务器上之后,我发现,一直在提示 404。看了 Nginx 的 Log 才发现,由于 Docker 权限的原因,无法将生成的图片写入到目标路径。修复了一下权限问题后,图片终于显示了出来。

既然都调通了,就放出来造福一下大众吧。不过写的比较冗余,也没有太多优化。

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

参考资料