🏞️

image-magick-lambda-layer を使ってオリジナル LGTM 画像を作ろう

2021/08/03に公開

LGTM画像といえば、LGTMoon、いつもお世話になっています。
でも、たまにはオリジナリティ出したいですよね。

そこで、今回は5秒でオリジナルLGTM画像を生成するサービスを作成します。

準備するもの

  • AWS API Gateway
  • lambda
  • image-magick-lambda-layer

lambda layer とは

Lambdaレイヤーは、追加のコードまたはデータを含むことができる .zip ファイルアーカイブです。レイヤーには、ライブラリ、 カスタムランタイム 、データ、または設定ファイルを含めることができます。レイヤーを使用すると、コードの共有と責任の分離を促進し、ビジネスロジックの記述をより迅速に繰り返すことができます。

素の lambda にライブラリを追加する方法。layer に分けることで処理速度を上げることができる。今回はAWSで用意されているレイヤーを使用するが、もちろん独自のレイヤーを作成することもできる。関数に追加できるLayerは5つまでで、合計サイズが250MB以下となる必要がある。

lambdaファンクションを作成する

lambda > 関数 に行き、「関数の作成」をします。
関数名は適当に lambda-image-magick(任意)、ランタイムは Node.js 14.x とします。

API Gateway、POST メソッドを作成する

これから作成する API Gateway の POST エンドポイントに画像のURLをリクエストし、 lambda で受け取れるようにしていきます。

{
  url: '画像のアドレス'
}

API Gateway から REST API を作成、アクション > メソッドの作成 から POST を作成。先程作成した lambda 関数名を指定します。

CORS を有効化する

「CORSを有効にして既存のCORSヘッダーを置換」をクリックし、「はい、既存の値を置き換えます」をクリックするとOPTIONSメソッドが作成されます。

image-magick-lambda-layer をデプロイする

image-magick-lambda-layer にアクセスしてデプロイをクリックします。

デプロイしただけでは使用できないので有効化します。

レイヤー > レイヤーを追加。
AWSレイヤーに出てこなかったため、ARN を指定してレイヤーを追加しました。

コードを用意

ImageMagick, GraphicsMagick を node.js で扱えるようにする gm を入れます。axios は画像ダウンロードで使用。

$ yarn init -y
$ yarn add axios gm 

ローカルで動作を確認する場合はインストールをお忘れなく。

$ brew install imagemagick
$ brew install graphicsmagick

重ねる lgtm.png を用意します。

LGTM画像は以下のように、適当に数パターンサイズを用意。
(当初は送られてきた画像のサイズに合わせてリサイズを考えましたが、lambda上に一時的に画像を保存しておくことができないため断念。)

├── assets
│   ├── lgtm.png
│   ├── lgtm100.png
│   ├── lgtm150.png
│   ├── lgtm200.png
│   ├── lgtm250.png
│   ├── lgtm300.png
│   ├── lgtm500.png
│   └── lgtm700.png
├── index.js
├── node_modules
├── package.json
└── utils.js

lambda 関数を作成

  1. 画像パスを受け取り、画像をダウンロード
  2. 画像とあらかじめ用意している lgtm.png と合成
  3. 合成したバッファをリサイズ(出力サイズは width 300px とした)
  4. response 返却
// index.js
const { downloadImage, composite, resize } = require('./utils');

exports.handler = async (event) => {
  try {
    const buf = await downloadImage(event.url); // 1
    const composited = await composite(buf); // 2
    const resized = await resize(composited, 300); // 3
    const base64 = 'data:image/png;base64,' + Buffer.from(resized).toString('base64');
    const response = {
      statusCode: 200,
      headers: {
          'Content-Type': 'image/png',
          'Access-Control-Allow-Origin': '*',
      },
      body: base64,
      isBase64Encoded: false,
    };
    return response; // 4
  } catch(e) {
    console.error(e);
  }
};

処理の詳細はこちら

// utils.js
const GM = require('gm');
const gm = GM.subClass({ imageMagick: true });
const axios = require('axios');

exports.downloadImage = async (url) => {
  const res = await axios.get(url, { responseType: 'arraybuffer' });
  return Buffer.from(res.data);
}

const getBufferSize = (buf) => {
  return new Promise((resolve, reject) => {
    gm(buf)
      .size((err, { width, height }) =>
        err ? reject(err) : resolve({ width, height }))
  })
}

const getLgtmPng = (width) => {
  if (width >= 1000) {
    return './assets/lgtm700.png'
  } else if (width < 1000 && width >= 800) {
    return './assets/lgtm500.png'
  } else if (width < 800 && width >= 500) {
    return './assets/lgtm300.png'
  } else if (width < 500 && width >= 300) {
    return './assets/lgtm150.png'
  } else if (width < 300) {
    return './assets/lgtm100.png'
  }
}

const getPosition = (lgtmPath, width, height) => {
  return new Promise((resolve, reject) => {
    gm(lgtmPath).size((err, size) => {
      const centerWidth = width / 2;
      const centerHeight = height / 2;
      const left = Math.floor(centerWidth - (size.width / 2));
      const top = Math.floor(centerHeight - (size.height / 2));
      const geometry = '+' + left + '+' + top;
      return err ? reject(err) : resolve(geometry)
    });
  });
}

const getCompositedBuffer = (buf, lgtmPath, geometry) => {
  return new Promise((resolve, reject) => {
    return gm(buf)
      .composite(lgtmPath)
      .geometry(geometry)
      .quality(100)
      .noProfile()
      .toBuffer((err, buffer) =>
         err ? reject(err) : resolve(buffer));
  })
}

exports.composite = async (buf) => {
  try {
    const { width, height } = await getBufferSize(buf) 
    const lgtmPath = getLgtmPng(width)
    const geometry = await getPosition(lgtmPath, width, height);
    return await getCompositedBuffer(buf, lgtmPath, geometry);
  } catch(e) {
    throw e;
  }
}

exports.resize = async (buf, width, height) => {
  return new Promise((resolve, reject) => {
    gm(buf)
      .resize(width, height)
      .noProfile()
      .toBuffer('PNG', (err, buffer) =>
        err ? reject(err) : resolve(buffer));
  });
};

デプロイ

コードができたら zip にします。

$ zip -r deploy.zip ./

lambda の画面から .zip ファイルをアップロード。

完成 🎉

ブラウザの任意の jpeg か png の画像のURLをエンドポイントにリクエストする bookmarklet を作成する。

(function () {
  const endpoint = 'your api endpoint'
  const imgs = document.querySelectorAll('img')
  const handler = async function (e) {
    e.preventDefault()
    try {
      const data = await (await fetch(endpoint, {
        method: 'POST',
        body: JSON.stringify({ url: e.target.src }),
      })).json()
      const link = document.createElement('a')
      link.href = data.body
      link.download = 'download.png'
      document.body.appendChild(link)
      link.click()
      link.remove()
    } catch (e) {
      alert(e)
    } finally {
      imgs.forEach((img) => {
        img.removeEventListener('click', handler)
      })
    }
  }
  imgs.forEach((img) => {
    img.addEventListener('click', handler)
  })
})();

Discussion