Cloudflare Workers の無料プランで画像を圧縮する
Cloudflare Workers の無料プランで画像を圧縮する
Next.js のプロジェクトを Cloudflare にデプロイする場合、問題になるのが Vercel が提供している画像の自動圧縮機能です。これを Cloudflare でも実現するためには、Cloudflare Workers が使えそうですが、無料プランでは画像の圧縮機能を提供していません。意地でも無料で実現したいという乞食精神に乗っ取り、画像変換コードを書くことにしました。
画像の変換方法
Cloudflare Workers では、一度のリクエストで処理できる CPU 時間は 10ms です。非同期アクセスの待ち時間は含まれないので、純粋な処理時間です。高速に処理するのならネイティブコードを使うのが一番ですが、もちろん使えません。そこで、WebAssembly を使うことにしました。最近 WebAssembly というと Rust が主流ですが、libwebp を直接使いたいので、今回は C++を選択しました。
コンパイラは Emscripten の emcc を使う
Emscripten は、C++ を JavaScript にコンパイルするためのツールです。これを使うことで、C++ で書いたコードを WebAssembly にコンパイルすることが出来ます。ということで、早速作ることにしました。
Emscripten では libpng と libjpeg がすぐに使える
Emscripten は既存のライブラリを取り込む仕組みとして ports というものを提供しています。これを使うことで、コンパイル時にオプション指定するだけで libpng や libjpeg を組み込んだ状態でビルドすることが出来ます。ただし、libwebp は組み込まれていません。自分でソースをダウンロードしてビルドに混ぜる必要があります。
Emscripten には SDL2 もいる
SDL2 は、画像の読み込みや描画を簡単に行うことが出来るライブラリです。これも ports に含まれています。libpng、libjpeg、libwebp を使えるようにしておけば、SDL にバイナリを放り込むだけで画像を読み込むことが出来ます。ただし webp 出力はサポートされていないので、そこは libwebp の命令を使うことになります。
ソースコード
ということで作りました。ものすごく簡単に書けるのですが、何故かネット上ではこういうコードは見つかりませんでした。
1#include <emscripten.h>2#include <emscripten/bind.h>3#include <emscripten/val.h>4#include <webp/encode.h>5#include <SDL_image.h>6#include <SDL2/SDL.h>78using namespace emscripten;910val optimize(std::string img_in, float width, float height, float quality) {11 SDL_RWops* rw = SDL_RWFromConstMem(img_in.c_str(), img_in.size());12 if (!rw) {13 return val::null();14 }1516 SDL_Surface* srcSurface = IMG_Load_RW(rw, 1);17 if (!srcSurface) {18 SDL_FreeRW(rw);19 return val::null();20 }2122 int srcWidth = srcSurface->w;23 int srcHeight = srcSurface->h;24 if (srcWidth == 0 || srcHeight == 0) {25 SDL_FreeSurface(srcSurface);26 return val::null();27 }2829 int outWidth = width?width:srcWidth;30 int outHeight = height?height:srcHeight;31 float aspectSrc = static_cast<float>(srcWidth) / srcHeight;32 float aspectDest = outWidth / outHeight;3334 if (aspectSrc > aspectDest) {35 outHeight = outWidth / aspectSrc;36 } else {37 outWidth = outHeight * aspectSrc;38 }3940 SDL_Surface* newSurface = SDL_CreateRGBSurfaceWithFormat(0, static_cast<int>(outWidth), static_cast<int>(outHeight), 32, SDL_PIXELFORMAT_RGBA32);41 if (!newSurface) {42 SDL_FreeSurface(srcSurface);43 return val::null();44 }4546 SDL_BlitScaled(srcSurface, nullptr, newSurface, nullptr);4748 SDL_FreeSurface(srcSurface);4950 uint8_t* img_out = nullptr;51 int stride = static_cast<int>(outWidth) * 4;52 size_t size = WebPEncodeRGBA(reinterpret_cast<uint8_t*>(newSurface->pixels), static_cast<int>(outWidth), static_cast<int>(outHeight), stride, quality, &img_out);5354 if (size == 0 || !img_out) {55 SDL_FreeSurface(newSurface);56 return val::null();57 }5859 val result = val::global("Uint8Array").new_(typed_memory_view(size, img_out));60 WebPFree(img_out);61 SDL_FreeSurface(newSurface);6263 return result;64}6566EMSCRIPTEN_BINDINGS(my_module) {67 function("optimize", &optimize);68}
Cloudflare Workers で動くパッケージを作る
Cloudflare Workers の JavaScript から WebAssembly を呼び出すには、wasm を import する必要があります。一般的な V8 エンジンのように、ネットワーク越しに wasm をダウンロードしてくることは出来ません。そのため、必ず wasm を含んだパッケージを作る必要があります。
ちなみに Cloudflare Workers では、Worker あたりのパッケージサイズが 1MB 以内と決められています。wasm のサイズは 1MB を超えていましたが、パッケージとして圧縮されると 400KB 程度になるのでセーフでした。
https://www.npmjs.com/package/wasm-image-optimization
Cloudflare Workers で動かす
先程の wasm-image-optimization パッケージを使って、Next.js の画像圧縮パラメータと互換性をもたせた Worker を作りました。これを使うと、Next.js の画像圧縮パラメータをそのまま使うことが出来ます。
1import { optimizeImage } from "wasm-image-optimization";2export interface Env {}34const isValidUrl = (url: string) => {5 try {6 new URL(url);7 return true;8 } catch (err) {9 return false;10 }11};1213const handleRequest = async (14 request: Request,15 _env: Env,16 ctx: ExecutionContext17): Promise<Response> => {18 const url = new URL(request.url);19 const params = url.searchParams;20 const imageUrl = params.get("url");21 if (!imageUrl || !isValidUrl(imageUrl)) {22 return new Response("url is required", { status: 400 });23 }24 const cache = caches.default;25 const cachedResponse = await cache.match(26 new Request(url.toString(), request)27 );28 if (cachedResponse) {29 return cachedResponse;30 }3132 const width = params.get("w");33 const quality = params.get("q");3435 const srcImage = await fetch(imageUrl, { cf: { cacheKey: imageUrl } })36 .then((res) => (res.ok ? res.arrayBuffer() : null))37 .catch((e) => null);3839 if (!srcImage) {40 return new Response("image not found", { status: 404 });41 }42 const image = await optimizeImage({43 image: srcImage,44 width: width ? parseInt(width) : undefined,45 quality: quality ? parseInt(quality) : undefined,46 });47 const response = new Response(image, {48 headers: {49 "Content-Type": "image/webp",50 "Cache-Control": "public, max-age=31536000, immutable",51 },52 });53 ctx.waitUntil(cache.put(request, response.clone()));54 return response;55};5657export default {58 fetch: handleRequest,59};
ちなみに next.config.js の設定で Workers のデプロイ先のアドレスを指定することによって、Workers で画像を圧縮することが出来ます。
1/**2 * @type { import("next").NextConfig}3 */4const config = {5 images: {6 path: "https://xxx.yyy.workers.dev/",7 },8};9export default config;
まとめ
Cloudflare Workers は、無料プランでも 100,000 リクエスト/日まで無料で使えます。それだけあれば、無料生活ユーザーには十分なのではないでしょうか。無ければ作る、それが無料で乗り切る秘訣です。