LoginSignup
35
19

More than 3 years have passed since last update.

FirebaseCloudFunctionsとcanvasだけで動的にOGP画像を生成し2020年を生き延びよう

Last updated at Posted at 2020-03-20

マジな話、思ってるよりかなりめんどくさいです
OGP芸、2020年フロントエンドエンジニアの試金石になるんじゃないでしょうか

SPAで動的OGP生成要件が出た

困りますよね。CGMやってると8割ぐらいで発生しそう
CGMってGCMとタイポするとGoogleの新製品みたいですよね。

構成

ちょっとググってみると分かる通り、色々な構成が考えられます。
例えば

  • Node.js側で生成し、ストレージにあげ、metaタグにURLをセット
  • UserAgentを見て、botならCloud Functionsに飛ばして、Cloud Functionsがmetaタグ付きのHTMLを返す

今回は

  • metaタグにCloud FunctionsのURLを渡す
    • 例えばhttps://asia-northeast1-hoge.cloudfunctions.net/ogp?text=${fuga}
  • Cloud FunctionsではStorageに該当画像があればリダイレクト、なければ生成してアップロードしてからリダイレクトする

で行こうと思います
これが一番シンプルなのでは?でもやっている人いなかった、なんか問題あるのか?

関係ないですが、Firebase*Nuxt.js関連の記事がめちゃくちゃ多くて、日本だと本当にVueが多いんだなと思いました

Firebase Cloud Functionsのセットアップ

「爆速で〜」とか「3分で出来る〜」とか全部フェイクで、セットアップだけで1時間ぐらい潰れます
代表的な罠を列挙しておきます

  • Nodeは8系が推奨されている(10系は使えない)のでnodebrewなりnodenvなりで用意しておく必要がある
  • デフォルトでTSLintを使おうとしてくる(コマンドライン対話時に拒否できる)のでESLint使う人は注意
  • デフォルトでnpmを使おうとしてくる(コマンドライン対話時に拒否できる)のでyarn使う人は注意

やることは


mkdir hoge
cd hoge
firebase init functions

で後は対話環境なんでよしなにやって下さい。

10分以内にリンタ環境まで整えられれば、あなたはFirebaseマスターです

ローカルデバッグ

firebase functions:shellでローカル対話環境が開きます
そこで${function名}:get('?text=太郎')等でデバッグ出来るので、活用していきましょう

注意点として、本番環境だと書き込み可能なストレージはtmp/ディレクトリのみです
ローカルだとどこでも書き込めると思うので、いざデプロイするとこれで例外発生したりします

実行ファイル

node-canvasでやります
node.jsではてなブログ風アイキャッチ画像を動的に生成するを大いに参考にしています

index.ts
import * as fs from 'fs'
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { createCanvas, loadImage } from 'canvas'

admin.initializeApp(functions.config().firebase)
const bucket = admin.storage().bucket() // デフォルトバケットじゃない人はここで指定

言うまでもないですが、権限はGOOGLE_APPLICATION_CREDENTIALSを見ています
ローカルで実行する際はよしなに設定して下さい

index.ts
exports.ogp = functions
  .region('asia-northeast1')
  .https.onRequest(async (req, res) => {
    const { text } = req.query
    const isExists = await checkIsExists(text)
    if (isExists) {
      const url = getUrl(`ogps/${text}.png`)
      res.redirect(url)
      return
    }
    const url = await createOgp(text)
    res.redirect(url)
  })

これが関数の本体部分になります
見れば分かるので次に行きましょう

index.ts
const checkIsExists = async (text: string): Promise<boolean> => {
  const filePath = `ogps/${text}.png`
  const isExists = await bucket.file(filePath).exists()
  return isExists[0]
}

const getUrl = (targetPath: string): string =>
  `https://firebasestorage.googleapis.com/v0/b/hoge/o/${encodeURIComponent(
    targetPath
  )}?alt=media`

bucketのogps/${text}.pngに画像を格納している想定ですね
getUrlはいちいちgetDownloadURL()するのダルいので使っています
publicに公開されているなら、たぶん動くはずです

index.ts
const fontStyle = {
  font: 'bold 85px "Noto Sans CJK JP"',
  lineHeight: 100,
  color: '#FFFFFF'
}

// Return result url
const createOgp = async (text: string): Promise<string> => {
  const loaclTargetPath = `/tmp/target.png`
  const localBasePath = '/tmp/base.png'
  const targetPath = `ogps/${text}.png`
  const basePath = 'ogps/base.png'

  // 背景画像のダウンロード
  await bucket.file(basePath).download({ destination: localBasePath })

  const canvas = createCanvas(1280, 670)
  const ctx = canvas.getContext('2d')
  // 背景画像の描画
  const baseImage = await loadImage(localBasePath)
  ctx.drawImage(baseImage, 0, 0, 1280, 670)

  // 文字列の書き込み
  ctx.font = fontStyle.font
  ctx.fillStyle = fontStyle.color
  ctx.textBaseline = 'top'
  const topPadding = 60
  const leftPadding = 50
  const rightPadding = 600
  const lineWidth = 1280 - leftPadding - rightPadding
  const lines = splitByMeasureWidth(name, lineWidth, ctx)
  const lineCount = lines.length

  for (let index = 0; index < lineCount; index++) {
    const element = lines[index]
    ctx.fillText(
      element,
      leftPadding,
      topPadding + fontStyle.lineHeight * index
    )
  }

  // tmpディレクトリへの書き込み
  const buf = canvas.toBuffer()
  fs.writeFileSync(loaclTargetPath, buf)

  // Storageにアップロード
  await bucket.upload(loaclTargetPath, { destination: targetPath })

  // tmpファイルの削除
  fs.unlinkSync(localBasePath)
  fs.unlinkSync(loaclTargetPath)

  return getUrl(targetPath)
}

const splitByMeasureWidth = (
  str: string,
  maxWidth: number,
  context: { measureText: CallableFunction }
): Array<string> => {
  // サロゲートペアを考慮した文字分割
  const chars = Array.from(str)
  let line = ''
  const lines = []
  for (let index = 0; index < chars.length; index++) {
    if (maxWidth <= context.measureText(line + chars[index]).width) {
      lines.push(line)
      line = chars[index]
    } else {
      line += chars[index]
    }
  }
  lines.push(line)
  return lines
}

この部分はほとんどnode.jsではてなブログ風アイキャッチ画像を動的に生成するに依っています

paddingやフォントはよしなに変更して下さい
Cloud Functionsで日本語フォントがちゃんと動くのか心配だったんですが、問題なく動いているようです

結果

良かったですね。

ogp.png

「下線を引きたい」「文字量に応じてフォントサイズを可変にしてほしい」などの要件が来た人は、もっとがんばってくださいね

いかがでしたか?

このブログは業務のデザイン上がってくるの待ちの時間に書いただけなので、コードや完成品をGitHubで共有したりしません。
皆さんもがんばってOGP芸を身に着け、2020年を生き延びましょうね

35
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
19