📸

wasm-image-optimization で一括画像最適化

2023/12/27に公開

TL;DR

以下のコードをデプロイし、Workers の管理画面から Routes を設定します。

workers のコード
import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import { optimizeImage } from "wasm-image-optimization";
import { cache } from "hono/cache";

const app = new Hono();

const via = createMiddleware((c, next) => {
  if (c.req.header("via")) {
    console.log("pass through", c.req.raw.url);
    return fetch(c.req.raw);
  }

  return next();
});

app.get("*", via, cache({ cacheName: "images" }), async (c) => {
  try {
    const res = await fetch(c.req.url);
    if (!res.ok) {
      return c.body(null, 404);
    }
    const buffer = await res.arrayBuffer();
    const image = await optimizeImage({
      image: buffer,
      width: 300,
      quality: 75,
    });

    return c.body(image, 200, {
      "Content-Type": "image/webp",
      "cache-control": "public, max-age=31536000, immutable",
    });
  } catch (e) {
    console.error(e);
    return c.body(null, 500);
  }
});

はじめに

Free プランでも一括画像最適化が可能です!この記事では、cloudflare の Speed Optimization 機能である Image Optimization 相当の処理を実現する方法を紹介します。この方法を使えば、画像を配信している URL を変更せずに画像の圧縮が行えます。

今回利用するもの

https://github.com/honojs/hono/

https://github.com/node-libraries/wasm-image-optimization

https://developers.cloudflare.com/workers/configuration/routing/routes/

Hono

cloudfalre workers 使うならこれ!というライブラリです。今回は domain cache と routes による自分自身へのリクエストを検出する middleware の作成に使いました。

wasm-image-optimization

buffer で受け取った画像を wasm で最適化するライブラリです。出力は webp のみ対応しているようです。

https://github.com/node-libraries/wasm-image-optimization/blob/master/src/workers/index.ts#L10-L20

詳しくは作者様の記事があるのでそちらを参照してください。

https://zenn.dev/sora_kumo/articles/wasm-image-optimization

Routes

画像にアクセスするとして通常ならば以下のような流れになります。
画像にリクエストが来ている

Routes を使うと画像にリクエストが行く前に前段に workers を挟むことができます。今回はここに wasm-image-optimization を使って画像を最適化する処理を挟みます。
画像の前段に Routes がある

Route を利用する上での注意

workers 上で origin へ proxy をするなら via という header をチェックする必要があります。origin へリクエストをしたつもりでも Route の機能でまた workers に戻ってくるので(上の画像の青線) via header をチェックして pass through する必要があります。

export default {
  async fetch(request, env, ctx) {
    // これがないと無限ループする
    if (request.headers.get("via")) {
      console.log("via");
      return fetch(request);
    }

    return handleRequest(request);
  },
};

async function handleRequest(request) {
  try {
    return fetch(request.url);
  } catch (e) {
    console.error(e);
    return new Response(null, 500);
  }
}

設定方法

ダッシュボードから設定するか、wrangler.toml に以下を追記します。

https://developers.cloudflare.com/workers/configuration/routing/routes/

Routes の設定

https://developers.cloudflare.com/workers/configuration/routing/routes/#set-up-a-route-in-wranglertoml

routes = [
  { pattern = "subdomain.example.com/*", zone_name = "example.com" }
  # or
  { pattern = "subdomain.example.com/*", zone_id = "<YOUR_ZONE_ID>" }
]

画像の最適化を行う workers を作る

  • hono の createMiddleware で via があるときは pass through する middleware を作る。
  • hono の cache middleware を雑に入れる
  • fetch で画像を取得して wasm-image-optimization で最適化する

終わり。

import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import { optimizeImage } from "wasm-image-optimization";
import { cache } from "hono/cache";

const app = new Hono();

const via = createMiddleware((c, next) => {
  if (c.req.header("via")) {
    console.log("pass through", c.req.raw.url);
    return fetch(c.req.raw);
  }

  return next();
});

app.get("*", via, cache({ cacheName: "images" }), async (c) => {
  try {
    const res = await fetch(c.req.url);
    if (!res.ok) {
      return c.body(null, 404);
    }
    const buffer = await res.arrayBuffer();
    const image = await optimizeImage({
      image: buffer,
      width: 300, // ここはお好みで
      quality: 75, // ここはお好みで
    });

    return c.body(image, 200, {
      "Content-Type": "image/webp",
      "cache-control": "public, max-age=31536000, immutable",
    });
  } catch (e) {
    console.error(e);
    return c.body(null, 500);
  }
});

export default app;

画像の最適化前後の比較

僕は R2 に画像を置いていて public domain を割り当てていたので、そこに対して Route を設定しました。
縮小するサイズにもよると思いますが 2~3Mb あった画像が 5~20Kb くらいになりました。

最適化前で画像を6枚読み込んでおり、1枚当たり2Mb程
最適化前

最適化後で画像を6枚読み込んでおり、1枚当たり20Kb程
最適化後

まとめ

  • Free プランでも Pro プラン相当の Image Optimization を実現できた
  • Routes を利用することによって URL を変更せずに画像の最適化ができた
  • wasm-image-optimization はめちゃくちゃ便利だが webp しか対応してないので注意が必要
    • 2023-12-28 png, jpg 対応がされました。

一応ほかの format 対応可能なのかを issue で聞いてみています。僕は現状 C++ と wasm がよくわからないので今自分でも何かできないか調べているところです。

https://github.com/node-libraries/wasm-image-optimization/issues/1

伸びてびっくりしちゃった。ありがとうございます。

https://x.com/naporin24690/status/1739773949066821825?s=20

2023-12-28 追記

wasm-image-optimization の output format に jpg, png が追加されました。

https://github.com/node-libraries/wasm-image-optimization/commit/0046d4912e9a15e086bae7bdb0ced04b0166db5e

この変更によって accept header をみてクライアントが対応している format で返却することができるようになりました。

この挙動は Cloudflare Images の Adaptive optimized format delivery を目指したものになります。

Adaptive optimized format delivery

以下のような挙動をします。accept header をみて format が変わるようになっています。

curl https://imagedelivery.net/xxxxxx -H "accept: image/png" -I

HTTP/2 200
content-type: image/png
curl https://imagedelivery.net/xxxxxx -H "accept: image/webp" -I

HTTP/2 200
content-type: image/webp

Adaptive optimized format delivery を実現する

accept header をみて wasm-image-optimization の format を変更することで実現します。

import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import { optimizeImage } from "wasm-image-optimization";
import { cache } from "hono/cache";

const app = new Hono();

const via = createMiddleware((c, next) => {
  if (c.req.header("via")) {
    console.log("pass through", c.req.raw.url);
    return fetch(c.req.raw);
  }

  return next();
});

// accept header から端末で利用できる format を返す
const imageType = (accept: string): "png" | "webp" => {
  if (accept.includes("image/webp")) {
    return "webp";
  }

  return "png";
};

app.get("*", via, cache({ cacheName: "images" }), async (c) => {
  try {
    const res = await fetch(c.req.url);
    if (!res.ok) {
      return c.body(null, 404);
    }
    const format = imageType(c.req.header("accept"));
    const buffer = await res.arrayBuffer();
    const image = await optimizeImage({
      image: buffer,
      width: 300,
      quality: 75,
      // ここを追加
      format,
    });

    return c.body(image, 200, {
      "Content-Type": `image/${format}`,
      "cache-control": "public, max-age=31536000, immutable",
    });
  } catch (e) {
    console.error(e);
    return c.body(null, 500);
  }
});

export default app;

Discussion