Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

自前でOG:Image生成メカニズムを構成する方法

こういう画像を作る仕組みを作った。

画像を生成したい人は世間にそこそこいるかもしれないし、自分は比較的?簡単な方法でそれを達成した。せっかくなのでやり方をみんなに教えようと思ってこの記事を書いている。

全てのソースコードは以下にOGImagekunという名前で公開している。

github.com

OG:Image

OG:Imageについて軽く説明しておく。知っている人は読み飛ばしていい。

Twitterやブログの埋め込み記事などで、このようなちょっと豪華な画像を見たことがあると思う。

最近のサイトでは、ユーザがより自分のコンテンツを見てくれるために、こういったサムネイルを表示するのが当たり前となっている。

これは、ウェブサイトや動画、記事などへのリンクをSNSなどに書き込むと、自動的にそのサムネイル的な画像を表示してくれる、という仕組みがそうしている。これはTwitterではTwitter Cardという仕組みで、FaceBookなどではOGPという仕組みでこれが実現されている。 いずれにしても仕組みは素朴で、コンテンツのHTMLの中に特定のタグで画像へのリンクを埋め込んでおくと、それが表示される、という仕組みになっている。例えばTwitter Cardでは、headタグの中に以下のようなタグを挿入すればよい:

<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://www.3qe.us/2023/20231006kyotojs_ogp.webp">

OGPでは、以下のような感じだ:

<meta property="og:image" content="https://www.3qe.us/2023/20231006kyotojs_ogp.webp">

ブログや技術サイトをシェアすると良い感じの画像が出てきてくれるのは、こうしたTwitter CardやOGPという仕組みがあるおかげだ。

画像の自作

さて、はてなブログといった世間一般のブログサービスには、自動的にこうしたサムネイル画像を生成してくれる仕組みがあるのだが、そうした手段を使わない場合は自分でサムネイル画像を用意しなければならない。サムネイル画像を用意しなければ目立たないので、たとえ良い記事であっても読んでもらえなくなってしまう。こうした競争はどうしても発生してしまうので、世間一般の水準にまで高めるためにはサムネイル画像を作ったほうがよい。例えば自分のサイトでコンテンツを直接ホスティングしているといった場合がこれにあてはまる。

しかも、最近はTwitterはシェアしたサイトの情報を表示せず、サムネイルだけでっかく表示するようになってしまったので、サムネイルの重要度は以前よりも遥かに高くなっている。

しかしながら毎回画像を作るのは骨が折れる。InkscapeやGIMPを毎回いじるのは手間だし、同じものをちゃんと作り続けるのは難しい。なにより、こんなこと手でやりたくない。そこで、一般企業と同じように、自動的にサムネイル画像を生成してくれる仕組みを自作することにした。

環境

今回は以下のような感じで仕組みを作った。特に重要なのはCloudflare Browser Rendering APIである。

  • 下敷となるSVGデータ
    • Inkscapeでしこしこ作った。1200x630pxになるようにするとTwitterで丁度良い。
  • SVG画像の上にいい感じに文字列を挿入したHTMLを出力する君
    • GoogleのCloudRunでホストした。最低ホスト数を0にしたので、暇なときは止まってくれるようになりお得。
    • 中身はScalaで適当に実装した。
  • HTMLをwebp形式でレンダリングしてくれる君
    • Cloudflare Browser Rendering APIをEdge workerが呼び出す。
    • 生成した画像は一定時間Cloudflare KVにキャッシュされ再利用される。

ざっくりアーキテクチャ

Cloudflare Browser Rendering APIは、要するにpupeteerをcloudflareのworker上で動かせるという便利な代物。webp出力に対応しているので、ファイルサイズを抑えつついい感じにサムネを生成できる。

HTML出力君

リポジトリのうち、workerディレクトリ以外の部分がこれに該当する。Scalaで動作するサーバであり、path parameterとして受け取ったタイトルをHTML上にレンダーして返す、いたって素朴なサーバである(http4sが動作している)。自前で静的ファイルを配信できるため、下敷となるSVGはnginxなどを利用せずに済み、単独で完全なHTMLを出力できる。sbt-packプラグインを活用してDockerイメージに変換し、Cloud Runにホストした。

blog.3qe.us

実際に出力されるHTMLは以下のような感じ:

<html lang="ja_JP">
<head>
        <meta charset="utf-8">
        <style>
         @import url('https://fonts.googleapis.com/css2?family=BIZ+UDPGothic:wght@700&display=swap');
         body { margin: 0; width: 1200px; height: 630px; }
         img { margin: 0; }
         #bg { width: 1200px; height: 630px; }
         #title { position: absolute; top: 100px; left: 100px; max-width: 1000px; font-family: 'BIZ UDPGothic', sans-serif;font-size: 48px; }
        </style>
    </head>
    <body>
        <script src="https://unpkg.com/budoux/bundle/budoux-ja.min.js"></script>
        <img id="bg" src="/ogp.svg" alt="background">
        <div id="title"><budoux-ja>この部分はspan</budoux-ja></div>
</body>
</html>

budouxはいい感じに日本語の改行位置を整えてくれるやつ。

developers-jp.googleblog.com

レンダリング君

さて、HTMLだけあっても最終的に画像にしなければサムネイルとして返すことができない。ここでCloudflare Browser Rendering APIの出番となる。

このAPIは現在betaとして提供されており、公式Discordに行って「ちょうだい」と言うともらえるようだ。

developers.cloudflare.com

以下のようなコードを書くと、/?title=タイトルの形式のリクエストを受けてKV2にスクリーンショットが保存され、クライアントにレスポンスできるworkerを作れる。詳細は参考文献を参照のこと。

// worker/src/worker.js
import puppeteer from "@cloudflare/puppeteer";

export default {
    async fetch(request, env) {
        const { searchParams } = new URL(request.url);
        let title = searchParams.get("title");
        let img;
        if (title) {
            let url = new URL(`https://xxxxxxxxxx-an.a.run.app/${title}`).toString(); // normalize
            img = await env.OGI_WORKER.get(url, { type: "arrayBuffer" });
            if (img === null) {
                const browser = await puppeteer.launch(env.MYBROWSER);
                const page = await browser.newPage();
                await page.goto(url);
                img = await page.screenshot({ type: 'webp', clip: { height: 630, width: 1200, x: 0, y: 0 }, quality: 95 });
                await env.OGI_WORKER.put(url, img, {
                    expirationTtl: 60 * 60 * 24,
                });
                await browser.close();
            }
            return new Response(img, {
                headers: {
                    "content-type": "image/webp",
                },
            });
        } else {
            return new Response(
                "Please add an ?title=title parameter"
            );
        }
    },
};
# worker/wrangler.toml
name = "worker"
main = "src/worker.js"
compatibility_date = "2023-10-02"
compatibility_flags = [ "nodejs_compat" ]

browser = { binding = "MYBROWSER" }
kv_namespaces = [
  { binding = "OGI_WORKER", id = "******", preview_id = "******" }
]

KVの名前空間はnpx wrangler kv:namespace create OGI_WORKERで切っておく。

wrangler deployを実行してデプロイした後、カスタムドメイン機能を使って自分のドメインに収めた。

まとめ

費用面ではCloudflareの費用とCloud Runの費用がかかるが、ほぼ無視できる値段で運用できているはずだ。自前でOG:Image生成メカニズムを構成したい方、いかがでしょう。僕のリポジトリをforkして適当にidとかSVGを差し替えたら同じことができるはず。早くBrowser Rendering APIがGAになるといいですね。

参考文献

zenn.dev

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?