[WebAssembly]Emscriptenによる画像最適化ライブラリの作り方
はじめに
Emscripten は C/C++のコードを WebAssembly に変換することが出来ます。また、標準で SDL2 をサポートしているため、画像処理ライブラリの作成に適しています。今回は、Emscripten を使って画像最適化ライブラリを作成する方法を紹介します。
画像最適化ライブラリは png や jpeg など比較的圧縮率が低いフォーマットから、webp や avif などの高圧縮率のフォーマットに変換したり、サイズの調整する機能を提供します。
作成したものがどんなものかは以下のリンクから確認できます。
https://www.npmjs.com/package/wasm-image-optimization
https://www.npmjs.com/package/wasm-image-optimization-avif
WebAssembly 化したときの利点は、ブラウザ上で動作させたり、Cloudflare や Deno のサーバ上の無料枠で動作させることが出来る点です。
環境構築
ローカル環境に Emscripten をインストールして設定を行うのはダルいので、Docker を使って環境構築を行います。
Dockerfile
1FROM emscripten/emsdk2WORKDIR /app
環境構築が完了しました。簡単ですね。
必要なパッケージのインストール
Emscripten は SDL2 によって png,jpeg,gif などは標準で使えるのですが、webp や avif などは追加で必要なパッケージを取ってくる必要があります。また、jpeg の exif 情報を使って回転情報を取得するためにも対応するパッケージが必要です。
Dockerfile
1FROM emscripten/emsdk2WORKDIR /app3RUN apt-get update && apt-get install -y dh-autoreconf ninja-build yasm &&\4 git clone https://github.com/webmproject/libwebp &&\5 git clone https://github.com/AOMediaCodec/libavif &&\6 git clone https://github.com/libexif/libexif &&\7 ln -s /app/libwebp/src/webp /emsdk/upstream/lib/clang/20/include/webp &&\8 ln -s /app/libavif/include/avif /emsdk/upstream/lib/clang/20/include/avif
ということで、必要なパッケージをインストールしました。ビルドに必要なツール類も入れておきます。さらにパッケージの include パスをシンボリックリンクで Emscripten のヘッダーファイルに追加します。
docker-compose.yml
1version: "3.7"2services:3 emcc:4 container_name: wasm-image-optimization5 build:6 context: .7 dockerfile: ./Dockerfile8 volumes:9 - app:/app10 - cache:/emsdk/upstream/emscripten/cache11 - ../Makefile:/app/Makefile12 - ../src:/app/src13 - ../dist:/app/dist14volumes:15 app:16 cache:
ホスト側の src ディレクトリの中に画像最適化ライブラリのソースコードを配置し、dist ディレクトリにビルド結果を出力出来るようにします。
Makefile の作成
webp と avif と exif のライブラリをビルドし、各パッケージをリンクできるようにします。さらに出力時に wasm と併用して使う.js ファイルを esm と cjs 形式で出力するように、二回ビルドを行います。
Makefile
ビルドが通るように試行錯誤してできたものは、控えめに言ってカオスです。
1SHELL=/bin/bash2WORKDIR=work3DISTDIR=dist4ESMDIR=$(DISTDIR)/esm5WORKERSDIR=$(DISTDIR)/workers6LIBDIR=libavif/ext78TARGET_ESM_BASE = $(notdir $(basename src/libImage.cpp))9TARGET_ESM = $(ESMDIR)/$(TARGET_ESM_BASE).js10TARGET_WORKERS = $(WORKERSDIR)/$(TARGET_ESM_BASE).js1112CFLAGS = -O3 -msimd128 \13 -Ilibwebp -Ilibwebp/src -Ilibavif/include -Ilibavif/third_party/libyuv/include -Ilibavif/ext/aom \14 -Ilibexif \15 -DAVIF_CODEC_AOM_ENCODE -DAVIF_CODEC_AOM_DECODE -DAVIF_CODEC_AOM=LOCAL1617CFLAGS_ASM = --bind \18 -s WASM=1 -s ALLOW_MEMORY_GROWTH=1 -s ENVIRONMENT=web -s DYNAMIC_EXECUTION=0 -s MODULARIZE=1 \19 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s USE_SDL_GFX=2 \20 -s SDL2_IMAGE_FORMATS='["png","jpg","webp","svg","avif"]'2122WEBP_SOURCES := $(wildcard libwebp/src/dsp/*.c) \23 $(wildcard libwebp/src/enc/*.c) \24 $(wildcard libwebp/src/utils/*.c) \25 $(wildcard libwebp/src/dec/*.c) \26 $(wildcard libwebp/sharpyuv/*.c)27AVIF_SOURCES := libavif/src/alpha.c \28 libavif/src/avif.c \29 libavif/src/colr.c \30 libavif/src/colrconvert.c \31 libavif/src/diag.c \32 libavif/src/exif.c \33 libavif/src/io.c \34 libavif/src/mem.c \35 libavif/src/obu.c \36 libavif/src/rawdata.c \37 libavif/src/read.c \38 libavif/src/reformat.c \39 libavif/src/reformat_libsharpyuv.c \40 libavif/src/reformat_libyuv.c \41 libavif/src/scale.c \42 libavif/src/stream.c \43 libavif/src/utils.c \44 libavif/src/write.c \45 libavif/src/codec_aom.c \46 libavif/third_party/libyuv/source/scale.c \47 libavif/third_party/libyuv/source/scale_common.c \48 libavif/third_party/libyuv/source/scale_any.c \49 libavif/third_party/libyuv/source/row_common.c \50 libavif/third_party/libyuv/source/planar_functions.c5152EXIF_SOURCES := $(wildcard libexif/libexif/*.c) \53 $(wildcard libexif/libexif/canon/*.c) \54 $(wildcard libexif/libexif/fuji/*.c) \55 $(wildcard libexif/libexif/olympus/*.c) \56 $(wildcard libexif/libexif/pentax/*.c)5758WEBP_OBJECTS := $(WEBP_SOURCES:.c=.o)59AVIF_OBJECTS := $(AVIF_SOURCES:.c=.o)60EXIF_OBJECTS := $(EXIF_SOURCES:.c=.o)6162.PHONY: all esm workers clean6364all: esm workers6566$(WEBP_OBJECTS) $(AVIF_OBJECTS): %.o: %.c | $(LIBDIR)/aom_build/libaom.a67 @emcc $(CFLAGS) -c $< -o $@6869$(LIBDIR)/aom_build/libaom.a:70 @echo Building aom...71 @cd $(LIBDIR) && ./aom.cmd && mkdir aom_build && cd aom_build && \72 emcmake cmake ../aom \73 -DENABLE_CCACHE=1 \74 -DAOM_TARGET_CPU=generic \75 -DENABLE_DOCS=0 \76 -DENABLE_TESTS=0 \77 -DCONFIG_ACCOUNTING=1 \78 -DCONFIG_INSPECTION=1 \79 -DCONFIG_MULTITHREAD=0 \80 -DCONFIG_RUNTIME_CPU_DETECT=0 \81 -DCONFIG_WEBM_IO=0 \82 -DCMAKE_BUILD_TYPE=Release && \83 make aom8485$(WORKDIR):86 @mkdir -p $(WORKDIR)8788$(WORKDIR)/webp.a: $(WORKDIR) $(WEBP_OBJECTS)89 @emar rcs $@ $(WEBP_OBJECTS)9091$(WORKDIR)/avif.a: $(WORKDIR) $(AVIF_OBJECTS)92 @emar rcs $@ $(AVIF_OBJECTS)9394$(WORKDIR)/libexif.a: $(EXIF_SOURCES)95 @cd libexif && autoreconf -i && emconfigure ./configure && cd libexif && emmake make96 @emar rcs $@ $(EXIF_OBJECTS)9798$(ESMDIR) $(WORKERSDIR):99 @mkdir -p $@100101esm: $(TARGET_ESM)102103$(TARGET_ESM): src/libImage.cpp $(WORKDIR)/webp.a $(WORKDIR)/avif.a $(WORKDIR)/libexif.a $(LIBDIR)/aom_build/libaom.a | $(ESMDIR)104 emcc $(CFLAGS) -o $@ $^ \105 $(CFLAGS_ASM) -s EXPORT_ES6=1106107workers: $(TARGET_WORKERS)108109$(TARGET_WORKERS): src/libImage.cpp $(WORKDIR)/webp.a $(WORKDIR)/avif.a $(WORKDIR)/libexif.a $(LIBDIR)/aom_build/libaom.a | $(WORKERSDIR)110 emcc $(CFLAGS) -o $@ $^ \111 $(CFLAGS_ASM)112 @rm $(WORKERSDIR)/$(TARGET_ESM_BASE).wasm113114clean:115 @echo Cleaning up...116 @rm -rf $(WORKDIR) $(LIBDIR)/aom_build $(DISTDIR)/esm $(DISTDIR)/workers
画像最適化ライブラリのソースを作成
集めた力を結集し、C++で画像最適化ライブラリを作成します。
src/libImage.cpp
1#include <emscripten.h>2#include <emscripten/bind.h>3#include <emscripten/val.h>4#include <webp/encode.h>5#include <SDL2/SDL2_rotozoom.h>6#include <SDL2/SDL_image.h>7#include <SDL2/SDL.h>8#include <libexif/exif-data.h>9#include <avif/avif.h>1011using namespace emscripten;1213EM_JS(void, js_console_log, (const char *str), {14 console.log(UTF8ToString(str));15});1617class MemoryRW18{19public:20 MemoryRW()21 {22 m_rw = SDL_AllocRW();23 m_rw->hidden.unknown.data1 = &m_buffer;24 m_rw->write = MemWrite;25 m_rw->close = MemClose;26 }27 ~MemoryRW()28 {29 SDL_FreeRW(m_rw);30 }31 operator SDL_RWops *() const { return m_rw; }32 size_t size() const { return m_buffer.size(); }33 const uint8_t *data() const { return m_buffer.data(); }3435protected:36 static size_t MemWrite(SDL_RWops *context, const void *ptr, size_t size, size_t num)37 {38 std::vector<uint8_t> *buffer = (std::vector<uint8_t> *)context->hidden.unknown.data1;39 const uint8_t *bytes = (const uint8_t *)ptr;40 buffer->insert(buffer->end(), bytes, bytes + size * num);41 return num;42 }43 static int MemClose(SDL_RWops *context)44 {45 return 0;46 }4748private:49 SDL_RWops *m_rw;50 std::vector<uint8_t> m_buffer;51};5253int getOrientation(std::string img)54{55 int orientation = 1;56 ExifData *ed = exif_data_new_from_data((const unsigned char *)img.c_str(), img.size());57 if (!ed)58 {59 return orientation;60 }61 ExifEntry *entry = exif_content_get_entry(ed->ifd[EXIF_IFD_0], EXIF_TAG_ORIENTATION);62 if (entry)63 {64 orientation = exif_get_short(entry->data, exif_data_get_byte_order(entry->parent->parent));65 }66 exif_data_unref(ed);67 return orientation;68}6970val optimize(std::string img_in, float width, float height, float quality, std::string format)71{72 int orientation = getOrientation(img_in);7374 SDL_RWops *rw = SDL_RWFromConstMem(img_in.c_str(), img_in.size());75 if (!rw)76 {77 return val::null();78 }7980 SDL_Surface *srcSurface = IMG_Load_RW(rw, 1);81 SDL_FreeRW(rw);82 if (!srcSurface)83 {84 return val::null();85 }8687 int srcWidth = srcSurface->w;88 int srcHeight = srcSurface->h;89 if (srcWidth == 0 || srcHeight == 0)90 {91 SDL_FreeSurface(srcSurface);92 return val::null();93 }9495 int outWidth = width ? width : srcWidth;96 int outHeight = height ? height : srcHeight;97 float aspectSrc = static_cast<float>(srcWidth) / srcHeight;98 float aspectDest = outWidth / outHeight;99100 if (aspectSrc > aspectDest)101 {102 outHeight = outWidth / aspectSrc;103 }104 else105 {106 outWidth = outHeight * aspectSrc;107 }108109 SDL_Surface *newSurface = zoomSurface(srcSurface, (float)outWidth / srcWidth, (float)outHeight / srcHeight, SMOOTHING_ON);110 SDL_FreeSurface(srcSurface);111 if (!newSurface)112 {113 return val::null();114 }115116 if (orientation > 1)117 {118 double angle = 0;119 double x = 1;120 double y = 1;121 switch (orientation)122 {123 case 2:124 x = -1.0;125 break;126 case 3:127 angle = 180.0;128 break;129 case 4:130 y = -1.0;131 break;132 case 5:133 angle = 90.0;134 x = -1.0;135 break;136 case 6:137 angle = 270.0;138 break;139 case 7:140 angle = 270.0;141 x = -1.0;142 break;143 case 8:144 angle = 90.0;145 break;146 }147 SDL_Surface *rotatedSurface = rotozoomSurfaceXY(newSurface, angle, x, y, SMOOTHING_ON);148 SDL_FreeSurface(newSurface);149 newSurface = rotatedSurface;150 }151152 if (format == "png" || format == "jpeg")153 {154 MemoryRW memoryRW;155 if (format == "png")156 {157 IMG_SavePNG_RW(newSurface, memoryRW, 1);158 }159 else160 {161 IMG_SaveJPG_RW(newSurface, memoryRW, 1, quality);162 }163 SDL_FreeSurface(newSurface);164 val result = val::null();165 if (memoryRW.size())166 {167 result = val::global("Uint8Array").new_(typed_memory_view(memoryRW.size(), memoryRW.data()));168 }169 return result;170 }171 else172 {173 if (newSurface->format->format != SDL_PIXELFORMAT_RGBA32)174 {175 SDL_Surface *convertedSurface = SDL_ConvertSurfaceFormat(newSurface, SDL_PIXELFORMAT_RGBA32, 0);176 SDL_FreeSurface(newSurface);177 if (convertedSurface == NULL)178 {179 return val::null();180 }181 newSurface = convertedSurface;182 }183 if (format == "webp")184 {185 uint8_t *img_out;186 val result = val::null();187 int width = newSurface->w;188 int height = newSurface->h;189 int stride = width * 4;190 size_t size = WebPEncodeRGBA(reinterpret_cast<uint8_t *>(newSurface->pixels), width, height, stride, quality, &img_out);191 if (size > 0 && img_out)192 {193 result = val::global("Uint8Array").new_(typed_memory_view(size, img_out));194 }195 WebPFree(img_out);196 SDL_FreeSurface(newSurface);197 return result;198 }199 else200 {201 int width = newSurface->w;202 int height = newSurface->h;203 avifImage *image = avifImageCreate(width, height, 8, AVIF_PIXEL_FORMAT_YUV444);204205 avifRGBImage rgb;206 avifRGBImageSetDefaults(&rgb, image);207 rgb.depth = 8;208 rgb.format = AVIF_RGB_FORMAT_RGBA;209 rgb.pixels = (uint8_t *)newSurface->pixels;210 rgb.rowBytes = width * 4;211212 if (avifImageRGBToYUV(image, &rgb) != AVIF_RESULT_OK)213 {214 return val::null();215 }216 avifEncoder *encoder = avifEncoderCreate();217 encoder->quality = (int)((quality) / 100 * 63);218 encoder->speed = 6;219220 avifRWData raw = AVIF_DATA_EMPTY;221222 avifResult encodeResult = avifEncoderWrite(encoder, image, &raw);223 avifEncoderDestroy(encoder);224 avifImageDestroy(image);225 if (encodeResult != AVIF_RESULT_OK)226 {227 return val::null();228 }229 val result = val::global("Uint8Array").new_(typed_memory_view(raw.size, raw.data));230 avifRWDataFree(&raw);231 return result;232 }233 }234}235236EMSCRIPTEN_BINDINGS(my_module)237{238 function("optimize", &optimize);239}240
ソースの内容は送られてきた画像を展開し回転情報を処理して、指定されたエンコーダーに変換を投げます。特別なロジックはいらないので、C++に慣れていない人でも多少試行錯誤すれば書けると思います。
ビルド
1docker compose -f docker/docker-compose.yml run --build --rm emcc make -j
これでビルドが行われ、wasm ファイルが出力されます。
まとめ
Emscripten を使って画像最適化ライブラリを作成する方法を紹介しました。最も面倒くさいのは、必要なライブラリを集めてリンク可能な状態にする部分です。便利なエコシステムなどありません。Makefile は地獄です。何をどうすればいいのか、カンが要求されます。
肝心の自分で組むコードの部分はただのツギハギです。スキルもへったくれもいらないというのがお分かりいただけると思います。