空雲 Blog

Eye catchCloudflare Workers の無料プランで画像を圧縮する その2

publication: 2023/12/29
update:2024/02/20

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>
7
8using namespace emscripten;
9
10class MemoryRW
11{
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(); }
27
28protected:
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 }
40
41private:
42 SDL_RWops *m_rw;
43 std::vector<uint8_t> m_buffer;
44};
45
46val 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 }
53
54 SDL_Surface *srcSurface = IMG_Load_RW(rw, 1);
55 SDL_FreeRW(rw);
56 if (!srcSurface)
57 {
58 return val::null();
59 }
60
61 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 }
68
69 int outWidth = width ? width : srcWidth;
70 int outHeight = height ? height : srcHeight;
71 float aspectSrc = static_cast<float>(srcWidth) / srcHeight;
72 float aspectDest = outWidth / outHeight;
73
74 if (aspectSrc > aspectDest)
75 {
76 outHeight = outWidth / aspectSrc;
77 }
78 else
79 {
80 outWidth = outHeight * aspectSrc;
81 }
82
83 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 }
89
90 SDL_BlitScaled(srcSurface, nullptr, newSurface, nullptr);
91 SDL_FreeSurface(srcSurface);
92
93 if (format == "png" || format == "jpeg")
94 {
95 MemoryRW memoryRW;
96 if (format == "png")
97 {
98 IMG_SavePNG_RW(newSurface, memoryRW, 1);
99 }
100 else
101 {
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 else
113 {
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}
127
128EMSCRIPTEN_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";
2
3const isValidUrl = (url: string) => {
4 try {
5 new URL(url);
6 return true;
7 } catch (err) {
8 return false;
9 }
10};
11
12const handleRequest = async (
13 request: Request,
14 _env: {},
15 ctx: ExecutionContext
16): Promise<Response> => {
17 const accept = request.headers.get("accept");
18 const isWebp =
19 accept
20 ?.split(",")
21 .map((format) => format.trim())
22 .some((format) => ["image/webp", "*/*", "image/*"].includes(format)) ??
23 true;
24
25 const url = new URL(request.url);
26
27 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 }
32
33 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 }
40
41 const width = params.get("w");
42 const quality = params.get("q");
43
44 const [srcImage, contentType] = await fetch(imageUrl, {
45 cf: { cacheKey: imageUrl },
46 })
47 .then(async (res) =>
48 res.ok
49 ? ([await res.arrayBuffer(), res.headers.get("content-type")] as const)
50 : []
51 )
52 .catch(() => []);
53
54 if (!srcImage) {
55 return new Response("image not found", { status: 404 });
56 }
57
58 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 }
68
69 const format = isWebp
70 ? "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};
90
91export default {
92 fetch: handleRequest,
93};

テストを書く

各画像の変換後の形式と、キャッシュの有無を確認しています。ちなみに Cloudflare Workers を jest でテストする方法もネットににまとまった情報がありませんでした。

1import { beforeAllAsync } from "jest-async";
2import { unstable_dev } from "wrangler";
3
4const 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}`;
7
8describe("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 });
17
18 afterAll(async () => {
19 const { worker } = await property;
20 await worker.stop();
21 });
22
23 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 環境では、こういったものが活躍する場が稀に出てくるので、また何か面白いものが思いついたら、何かしら作っていこうと思っています。