Cloudflare Workers の無料プランで画像を圧縮する その2
accept で変換形式を出し分ける
https://next-blog.croud.jp/contents/161ec5f7-3b10-4ac4-9dd9-7fadf744a39c
こちらの続きになります。
前回は、Cloudflare Workers で画像を圧縮し、Webp 形式で出力するを紹介しました。今回は、Accept ヘッダーに応じて変換形式を出し分ける方法を紹介します。そのためにはまず、WebAssembly で画像を変換するコードに、jpeg と png の出力を追加する必要があります。ということで実装しました。
画像変換のコード
前回のコードに IMG_SavePNG_RW と IMG_SaveJPG_RW を追加しています。SDL の標準機能としてサポートされているので簡単に実装できましたと言いたいところなのですが、ファイルではなくバイナリで出力するサンプルがネット上に見つからず、やり方を見つけ出すのに苦労しました。最終的に MemoryRW というクラスを作って、そこでバイナリを受け取るようにしました。
前回からの追加で avif の入力もサポートしています。ただし出力機能は断念しました。理由はエンコード部分のサイズがでかすぎて、圧縮状態でも 1MB を遥かに超える wasm が生成されたからです。今後 Cloudflare Workers のフリープランの許容サイズが増えたら実装を加えます。
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;910class MemoryRW11{12public:13 MemoryRW()14 {15 m_rw = SDL_AllocRW();16 m_rw->hidden.unknown.data1 = &m_buffer;17 m_rw->write = MemWrite;18 m_rw->close = MemClose;19 }20 ~MemoryRW()21 {22 SDL_FreeRW(m_rw);23 }24 operator SDL_RWops *() const { return m_rw; }25 size_t size() const { return m_buffer.size(); }26 const uint8_t *data() const { return m_buffer.data(); }2728protected:29 static size_t MemWrite(SDL_RWops *context, const void *ptr, size_t size, size_t num)30 {31 std::vector<uint8_t> *buffer = (std::vector<uint8_t> *)context->hidden.unknown.data1;32 const uint8_t *bytes = (const uint8_t *)ptr;33 buffer->insert(buffer->end(), bytes, bytes + size * num);34 return num;35 }36 static int MemClose(SDL_RWops *context)37 {38 return 0;39 }4041private:42 SDL_RWops *m_rw;43 std::vector<uint8_t> m_buffer;44};4546val optimize(std::string img_in, float width, float height, float quality, std::string format)47{48 SDL_RWops *rw = SDL_RWFromConstMem(img_in.c_str(), img_in.size());49 if (!rw)50 {51 return val::null();52 }5354 SDL_Surface *srcSurface = IMG_Load_RW(rw, 1);55 SDL_FreeRW(rw);56 if (!srcSurface)57 {58 return val::null();59 }6061 int srcWidth = srcSurface->w;62 int srcHeight = srcSurface->h;63 if (srcWidth == 0 || srcHeight == 0)64 {65 SDL_FreeSurface(srcSurface);66 return val::null();67 }6869 int outWidth = width ? width : srcWidth;70 int outHeight = height ? height : srcHeight;71 float aspectSrc = static_cast<float>(srcWidth) / srcHeight;72 float aspectDest = outWidth / outHeight;7374 if (aspectSrc > aspectDest)75 {76 outHeight = outWidth / aspectSrc;77 }78 else79 {80 outWidth = outHeight * aspectSrc;81 }8283 SDL_Surface *newSurface = SDL_CreateRGBSurfaceWithFormat(0, static_cast<int>(outWidth), static_cast<int>(outHeight), 32, SDL_PIXELFORMAT_RGBA32);84 if (!newSurface)85 {86 SDL_FreeSurface(srcSurface);87 return val::null();88 }8990 SDL_BlitScaled(srcSurface, nullptr, newSurface, nullptr);91 SDL_FreeSurface(srcSurface);9293 if (format == "png" || format == "jpeg")94 {95 MemoryRW memoryRW;96 if (format == "png")97 {98 IMG_SavePNG_RW(newSurface, memoryRW, 1);99 }100 else101 {102 IMG_SaveJPG_RW(newSurface, memoryRW, 1, quality);103 }104 SDL_FreeSurface(newSurface);105 val result = val::null();106 if (memoryRW.size())107 {108 result = val::global("Uint8Array").new_(typed_memory_view(memoryRW.size(), memoryRW.data()));109 }110 return result;111 }112 else113 {114 uint8_t *img_out;115 val result = val::null();116 int stride = static_cast<int>(outWidth) * 4;117 size_t size = WebPEncodeRGBA(reinterpret_cast<uint8_t *>(newSurface->pixels), static_cast<int>(outWidth), static_cast<int>(outHeight), stride, quality, &img_out);118 if (size > 0 && img_out)119 {120 result = val::global("Uint8Array").new_(typed_memory_view(size, img_out));121 }122 WebPFree(img_out);123 SDL_FreeSurface(newSurface);124 return result;125 }126}127128EMSCRIPTEN_BINDINGS(my_module)129{130 function("optimize", &optimize);131}
Cloudflare Workers で動かす
http のリクエストヘッダの accept を確認して、webp で出力するかどうか判断するようにしました。webp が使えない場合、元の画像が jpeg なら jpeg、それ以外は png 形式で出力します。クエリパラメータとして w(幅)と q(クオリティ 1 ~ 100)を設定できます。
1import { optimizeImage } from "wasm-image-optimization";23const isValidUrl = (url: string) => {4 try {5 new URL(url);6 return true;7 } catch (err) {8 return false;9 }10};1112const handleRequest = async (13 request: Request,14 _env: {},15 ctx: ExecutionContext16): Promise<Response> => {17 const accept = request.headers.get("accept");18 const isWebp =19 accept20 ?.split(",")21 .map((format) => format.trim())22 .some((format) => ["image/webp", "*/*", "image/*"].includes(format)) ??23 true;2425 const url = new URL(request.url);2627 const params = url.searchParams;28 const imageUrl = params.get("url");29 if (!imageUrl || !isValidUrl(imageUrl)) {30 return new Response("url is required", { status: 400 });31 }3233 const cache = caches.default;34 url.searchParams.append("webp", isWebp.toString());35 const cacheKey = new Request(url.toString());36 const cachedResponse = await cache.match(cacheKey);37 if (cachedResponse) {38 return cachedResponse;39 }4041 const width = params.get("w");42 const quality = params.get("q");4344 const [srcImage, contentType] = await fetch(imageUrl, {45 cf: { cacheKey: imageUrl },46 })47 .then(async (res) =>48 res.ok49 ? ([await res.arrayBuffer(), res.headers.get("content-type")] as const)50 : []51 )52 .catch(() => []);5354 if (!srcImage) {55 return new Response("image not found", { status: 404 });56 }5758 if (contentType && ["image/svg+xml", "image/gif"].includes(contentType)) {59 const response = new Response(srcImage, {60 headers: {61 "Content-Type": contentType,62 "Cache-Control": "public, max-age=31536000, immutable",63 },64 });65 ctx.waitUntil(cache.put(cacheKey, response.clone()));66 return response;67 }6869 const format = isWebp70 ? "webp"71 : contentType === "image/jpeg"72 ? "jpeg"73 : "png";74 const image = await optimizeImage({75 image: srcImage,76 width: width ? parseInt(width) : undefined,77 quality: quality ? parseInt(quality) : undefined,78 format,79 });80 const response = new Response(image, {81 headers: {82 "Content-Type": `image/${format}`,83 "Cache-Control": "public, max-age=31536000, immutable",84 date: new Date().toUTCString(),85 },86 });87 ctx.waitUntil(cache.put(cacheKey, response.clone()));88 return response;89};9091export default {92 fetch: handleRequest,93};
テストを書く
各画像の変換後の形式と、キャッシュの有無を確認しています。ちなみに Cloudflare Workers を jest でテストする方法もネットににまとまった情報がありませんでした。
1import { beforeAllAsync } from "jest-async";2import { unstable_dev } from "wrangler";34const images = ["test01.png", "test02.jpg", "test03.avif", "test04.gif"];5const imageUrl = (image: string) =>6 `https://raw.githubusercontent.com/SoraKumo001/cloudflare-workers-image-optimization/master/images/${image}`;78describe("Wrangler", () => {9 const property = beforeAllAsync(async () => {10 const worker = await unstable_dev("./src/index.ts", {11 experimental: { disableExperimentalWarning: true },12 ip: "127.0.0.1",13 });14 const time = Date.now();15 return { worker, time };16 });1718 afterAll(async () => {19 const { worker } = await property;20 await worker.stop();21 });2223 test("GET /", async () => {24 const { worker } = await property;25 const res = await worker.fetch("/");26 expect(res.status).toBe(400);27 expect(await res.text()).toBe("url is required");28 });29 test("not found", async () => {30 const { worker, time } = await property;31 for (let i = 0; i < images.length; i++) {32 const url = imageUrl("_" + images[i]);33 const res = await worker.fetch(`/?url=${encodeURI(url)}&t=${time}`, {34 headers: { accept: "image/webp,image/jpeg,image/png" },35 });36 expect(res.status).toBe(404);37 }38 });39 test("webp", async () => {40 const { worker, time } = await property;41 const types = ["webp", "webp", "webp", "gif"];42 for (let i = 0; i < images.length; i++) {43 const url = imageUrl(images[i]);44 const res = await worker.fetch(`/?url=${encodeURI(url)}&t=${time}`, {45 headers: { accept: "image/webp,image/jpeg,image/png" },46 });47 expect(res.status).toBe(200);48 expect(Object.fromEntries(res.headers.entries())).toMatchObject({49 "content-type": `image/${types[i]}`,50 });51 expect(res.headers.get("cf-cache-status")).toBeNull();52 }53 });54 test("webp(cache)", async () => {55 const { worker, time } = await property;56 const types = ["webp", "webp", "webp", "gif"];57 for (let i = 0; i < images.length; i++) {58 const url = imageUrl(images[i]);59 const res = await worker.fetch(`/?url=${encodeURI(url)}&t=${time}`, {60 headers: { accept: "image/webp,image/jpeg,image/png" },61 });62 expect(res.status).toBe(200);63 expect(Object.fromEntries(res.headers.entries())).toMatchObject({64 "content-type": `image/${types[i]}`,65 "cf-cache-status": "HIT",66 });67 }68 });69 test("not webp", async () => {70 const { worker, time } = await property;71 const types = ["png", "jpeg", "png", "gif"];72 for (let i = 0; i < images.length; i++) {73 const url = imageUrl(images[i]);74 const res = await worker.fetch(`/?url=${encodeURI(url)}&t=${time}`, {75 headers: { accept: "image/jpeg,image/png" },76 });77 expect(res.status).toBe(200);78 expect(Object.fromEntries(res.headers.entries())).toMatchObject({79 "content-type": `image/${types[i]}`,80 });81 expect(res.headers.get("cf-cache-status")).toBeNull();82 }83 });84 test("not webp(cache)", async () => {85 const { worker, time } = await property;86 const types = ["png", "jpeg", "png", "gif"];87 for (let i = 0; i < images.length; i++) {88 const url = imageUrl(images[i]);89 const res = await worker.fetch(`/?url=${encodeURI(url)}&t=${time}`, {90 headers: { accept: "image/jpeg,image/png" },91 });92 expect(res.status).toBe(200);93 expect(Object.fromEntries(res.headers.entries())).toMatchObject({94 "content-type": `image/${types[i]}`,95 "cf-cache-status": "HIT",96 });97 }98 });99});
まとめ
この WebAssembly を使用したプログラムは C++で書いているわけですが、Web アプリ時代に再び C++を使うことになるとは感慨深いものがあります。CloudflareWorkers や DenoDeploy などの制限された Edge 環境では、こういったものが活躍する場が稀に出てくるので、また何か面白いものが思いついたら、何かしら作っていこうと思っています。