画像を任意のフォーマット&サイズに変換するLambda関数を作成する

任意のBase64にエンコードされた画像をリクエストで送るだけで、フォーマットとサイズを変換してレスポンスで返してくれるLambda関数を作成する方法です。

こういう汎用的なAPIが欲しくなる場面って割とあると思うので、すぐに作成できるように手順を残します。

## 例

変換前
![3ce943ff-a81f-45bd-ab74-6560e0d33faf.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227791/dada9250-3380-4a0f-315a-85dc1e4efd58.jpeg)

変換後(100x100に収まるように設定)
![4f3654ed-8706-4854-8d5d-255548a126b3.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227791/2131673c-0a38-04fd-de63-34da1e831f50.jpeg)



## ユースケース

- 画像形式もサイズもバラバラな画像を、512 x 512以下のJPEG画像に変換する
- 画像処理のために、小さな画像を1000x1000のPNG画像にする
- 例えば、ブラウザからプロフィール画像をS3や任意のAPIにアップロードする前に、このAPIでファイルを小さくしておく

## 開発環境

```sh
$ node -v
v16.15.1
```

nodeバージョンが 16 でない場合は、`n` などを使用してバージョンを 16 にしておいてください。


## Lambda関数の作成手順

### 1. sharp のインストール

適当なプロジェクトを作って、sharpをインストールします。

```sh
$ npm init -y
$ SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install --arch=x64 --platform=linux --libc=glibc sharp
```

Linux以外でビルドしたsharpはLambdaで動かないので、上記のように追加のコマンドを使用する必要があります。

> 参考: https://sharp.pixelplumbing.com/install#aws-lambda


### 2. Lambda Layer の作成

Layer にアップロードする zip ファイルを作成します。

```sh
$ mkdir nodejs
$ mv node_modules nodejs
$ zip -r layer.zip nodejs
```

AWSコンソールで Lambda Layer を作成し、zip ファイルをアップロードします。
この Lambda Layer は、`Node.js 16.x` と `x86_64` に互換性を持つように設定します。


### 3. Lambda関数の作成

AWS コンソールから、Lambda関数を作成します。
ランタイムは `Node.js 16.x`、 アーキテクチャは `x86_64` を指定します。
また、「関数 URL を有効化」 にチェックを入れておきます。

作成したら、先ほど作成した Lambda Layer を紐付けます。
タイムアウトは `1分` に変更します。
メモリは `1024MB` くらいにしておくと、3000 x 3000 ぐらいの大きな画像でも生成できます。


### 4. コードを書き込む

こんな感じでプログラムを設定しておきます。

```js
const sharp = require("sharp");

exports.main = async (event) => {
  const { base64_image, toFormat, ...resizeOption } = JSON.parse(event.body);
  const base64 = base64_image.replace(/data:.+;base64,/, "");

  const resizedImage = sharp(Buffer.from(base64, "base64"))
    .resize(resizeOption)
    .toFormat(toFormat);

  const resizedImageBuf = await resizedImage.toBuffer();
  const resizedBase64 = resizedImageBuf.toString("base64");

  return {
    statusCode: 200,
    headers: {
      "Access-Control-Allow-Methods": "POST",
      "Access-Control-Allow-Headers": "Content-Type",
      "Access-Control-Allow-Origin": "*",
    },
    body: JSON.stringify({ base64_image: resizedBase64 }),
  };
};
```


## 実際に使ってみる

React/TypeScriptから利用する例です。
ファイルを選択すると、800x800に収まるように変換された画像のBase64をコンソールに出力します。
長いですが、半分はSharpの型定義です。

```tsx:some_page.tsx
type Input = {
  base64_image: string;
  toFormat:
    | "avif"
    | "dz"
    | "fits"
    | "gif"
    | "heif"
    | "input"
    | "jpeg"
    | "jpg"
    | "magick"
    | "openslide"
    | "pdf"
    | "png"
    | "ppm"
    | "raw"
    | "svg"
    | "tiff"
    | "tif"
    | "v"
    | "webp";
  width?: number | undefined;
  height?: number | undefined;
  // 指定した寸法に合わせてどうやって画像をリサイズするか。デフォルトでcover。
  fit?: "contain" | "cover" | "fill" | "inside" | "outside" | undefined;
  // fitがcoverかcontainのときにどこに画像を配置するか。
  position?: number | string | undefined;
  // fitにcontainを指定したときの背景色。デフォルトで#000。
  background?: string | undefined;
  // 画像縮小に使用するカーネル
  kernel?:
    | "nearest"
    | "cubic"
    | "mitchell"
    | "lanczos2"
    | "lanczos3"
    | undefined;
  // 画像の解像度を上げないようにする。
  withoutEnlargement?: boolean | undefined;
  // 画像の解像度を下げないようにする。
  withoutReduction?: boolean | undefined;
  // JPEGとWebPのシュリンクオンロード機能を使用する。画像によってはモアレが発生する可能性あり。
  fastShrinkOnLoad?: boolean | undefined;
};

const convert = async (input: Input): Promise<{ base64_image: string }> => {
  const response: Input = await fetch(
    "https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws/", // 関数URL
    {
      body: JSON.stringify(input),
      mode: "cors",
      method: "POST",
    }
  ).then((res) => res.json());
  return response;
};

const uploadToClient = (event: any) => {
  if (event.target.files[0]) {
    const reader = new FileReader();
    const file = (event.target.files as FileList)[0];

    reader.onload = async (event) => {
      const base64_image = (event.currentTarget as any).result;
      const { base64_image: resizedBase64 } = await convert({
        base64_image,
        width: 800,
        height: 800,
        fit: "inside",
        withoutEnlargement: true,
        toFormat: "jpg",
      });
      console.log(resizedBase64);
    };
    reader.readAsDataURL(file);
  }
};

const SomeComponent = () => <input type="file" onChange={uploadToClient} />;
export default SomeComponent;
```


## fitプロパティの違いについて

言葉で説明するより、見た方が早いと思うので、実際にやってみます。
個人的に一番よく使うのは `inside` です。

元画像(1000x600)
![ab898f5f-617b-4256-aeac-c82da0f3b9b1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227791/4412b37e-2337-0f2d-17aa-2e9c5307a4f6.jpeg)


### fit: contain

画像が収まるように、500x500の画像が生成される。
背景色は、`background` で指定した色になり、デフォルトでは黒になる。

```js
const option = {
    width: 500,
    height: 500,
    fit: "contain",
}
```

![77bb548d-6e49-4cdf-bacf-c0fb0753c470.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227791/a01569b2-21b1-cc2a-efc1-a587a06a8936.jpeg)


### fit: cover

500x500になるように、画像が切り取られる。
画像の切り取り方は `position` で指定した方法になり、デフォルトでは `"centre"` が指定される。

```js
const option = {
    width: 500,
    height: 500,
    fit: "cover",
}
```

![cd32fe0d-cea7-476a-b445-9c43c5bc0c17.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227791/ec6f619f-a47c-cbd7-2a98-5dec48b87f23.jpeg)

### fit: fill

縦横比は無視され、500x500の画像が生成される。

```js
const option = {
    width: 500,
    height: 500,
    fit: "fill",
}
```

![7d81dc26-e5d7-45eb-9497-9359e5b99769.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227791/36882e46-39ff-a368-b822-9a5a45d68778.jpeg)


### fit: inside

**長い方の辺が500**になるようにリサイズされる。
今回の例では横の方が長いので、500x300の画像が生成される。


```js
const option = {
    width: 500,
    height: 500,
    fit: "inside",
}
```

![4b6a1218-b030-4d00-a8b7-afe56ee57055.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227791/3a162f06-608e-a53a-a09b-2ecdb9f2032f.jpeg)


### fit: outside

**短い方の辺が500**になるようにリサイズされる。
今回の例では縦の方が短いので、833x500の画像が生成される。

```js
const option = {
    width: 500,
    height: 500,
    fit: "outside",
}
```

![e4d1477c-9c40-44bb-85c7-22a0b63e8d23.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227791/95829ed8-f5fa-89d2-083a-7454b0d57f32.jpeg)


## 変換にかかる時間

ほとんどの画像は2秒以内にレスポンスが返ります。
あまりにも大きい画像を処理する場合、タイムアウト上限が足りない、もしくはメモリが足りなくなることがあります。
その場合は、Lambdaの設定から上限を引き上げてください。
関数URLを使用せず、API Gatewayを使用する場合は、API Gatewayのタイムアウト上限が30秒なので、ご注意ください。