Cloudflare Workers で OGP 画像を生成
publication: 2025/02/02
update:2025/02/17
OGP 画像生成に使うライブラリ
| ライブラリ | 用途 |
|---|---|
| satori | 仮想 DOM を SVG 形式に変換 |
| yoga-wasm-web | satori が使用しているレイアウトエンジン |
| svg2png-wasm | SVG を PNG へ変換 |
| wasm-image-optimization | satori 未対応の WebP や Avif 画像を組み込めるように変換 |
出回っているサンプルは svg から png の変換に resvg を使っているものが多いですが svg2png-wasm の方が高速で安定しています。
コード
src/createOGP.ts
フォントと絵文字を必要に応じてダウンロードしてキャッシュに保存し、png 形式で OGP 画像を作成するコードです。
1import satori, { init } from "satori/wasm";2import initYoga from "yoga-wasm-web";3import yogaWasm from "yoga-wasm-web/dist/yoga.wasm";4import { svg2png, initialize } from "svg2png-wasm";5import wasm from "svg2png-wasm/svg2png_wasm_bg.wasm";67init(await initYoga(yogaWasm));8await initialize(wasm);910const cache = await caches.open("cloudflare-ogp");1112type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;13type FontStyle = "normal" | "italic";14type FontSrc = {15 data: ArrayBuffer | string;16 name: string;17 weight?: Weight;18 style?: FontStyle;19 lang?: string;20};21type Font = Omit<FontSrc, "data"> & { data: ArrayBuffer | ArrayBufferView };2223const downloadFont = async (fontName: string) => {24 return await fetch(25 `https://fonts.googleapis.com/css2?family=${encodeURI(fontName)}`26 )27 .then((res) => res.text())28 .then(29 (css) =>30 css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/)?.[1]31 )32 .then(async (url) => {33 return url !== undefined34 ? fetch(url).then((v) =>35 v.status === 200 ? v.arrayBuffer() : undefined36 )37 : undefined;38 });39};4041const getFonts = async (42 fontList: string[],43 ctx: ExecutionContext44): Promise<Font[]> => {45 const fonts: Font[] = [];46 for (const fontName of fontList) {47 const cacheKey = `http://font/${encodeURI(fontName)}`;4849 const response = await cache.match(cacheKey);50 if (response) {51 fonts.push({52 name: fontName,53 data: await response.arrayBuffer(),54 weight: 400,55 style: "normal",56 });57 } else {58 const data = await downloadFont(fontName);59 if (data) {60 ctx.waitUntil(cache.put(cacheKey, new Response(data)));61 fonts.push({ name: fontName, data, weight: 400, style: "normal" });62 }63 }64 }65 return fonts.flatMap((v): Font[] => (v ? [v] : []));66};6768const createLoadAdditionalAsset = ({69 ctx,70 emojis,71}: {72 ctx: ExecutionContext;73 emojis: {74 url: string;75 upper?: boolean;76 }[];77}) => {78 const getEmojiSVG = async (code: string) => {79 const cacheKey = `http://emoji/${encodeURI(80 JSON.stringify(emojis)81 )}/${code}`;82 for (const { url, upper } of emojis) {83 const emojiURL = `${url}${84 upper === false ? code.toLocaleLowerCase() : code.toUpperCase()85 }.svg`;86 let response = await cache.match(cacheKey);87 if (!response) {88 response = await fetch(emojiURL);89 if (response.status === 200) {90 ctx.waitUntil(cache.put(cacheKey, response.clone()));91 }92 }93 if (response.status === 200) {94 return await response.text();95 }96 }97 return undefined;98 };99100 const loadEmoji = async (segment: string): Promise<string | undefined> => {101 const codes = Array.from(segment).map((char) => char.codePointAt(0));102 const isZero = codes.includes(0x200d);103 const code = codes104 .filter((code) => isZero || code !== 0xfe0f)105 .map((v) => v?.toString(16))106 .join("-");107 return getEmojiSVG(code);108 };109110 const loadAdditionalAsset = async (code: string, segment: string) => {111 if (code === "emoji") {112 const svg = await loadEmoji(segment);113 if (!svg) return segment;114 return `data:image/svg+xml;base64,${btoa(svg)}`;115 }116 return [];117 };118119 return loadAdditionalAsset;120};121122export const createOGP = async (123 element: JSX.Element,124 {125 fonts,126 emojis,127 ctx,128 width,129 height,130 scale,131 }: {132 ctx: ExecutionContext;133 fonts: string[];134 emojis?: {135 url: string;136 upper?: boolean;137 }[];138 width: number;139 height?: number;140 scale?: number;141 }142) => {143 const fontList = await getFonts(fonts, ctx);144 const svg = await satori(element, {145 width,146 height,147 fonts: fontList,148 loadAdditionalAsset: emojis149 ? createLoadAdditionalAsset({ ctx, emojis })150 : undefined,151 });152 return await svg2png(svg, { scale });153};
src/index.tsx
こちらは OGP 用の仮想 DOM を作っています。外部から受け取る画像に関しては、satori で取り扱えるように png 形式に変換するようにしています。
こちらは必要に応じてカスタマイズしてください。
1import React from "react";2import { createOGP } from "./createOGP";3import { optimizeImage } from "wasm-image-optimization";45const convertImage = async (url: string | null) => {6 const response = url ? await fetch(url) : undefined;7 if (response) {8 const contentType = response.headers.get("Content-Type");9 const imageBuffer = await response.arrayBuffer();10 if (contentType?.startsWith("image/")) {11 if (["image/png", "image/jpeg"].includes(contentType)) {12 return [contentType, imageBuffer as ArrayBuffer] as const;13 }14 const image = await optimizeImage({ image: imageBuffer, format: "png" });15 if (image) {16 return ["image/png", image] as const;17 }18 }19 }20 return [];21};2223const outputOGP = async (24 request: Request,25 _env: object,26 ctx: ExecutionContext27): Promise<Response> => {28 const url = new URL(request.url);29 if (url.pathname !== "/") {30 return new Response(null, { status: 404 });31 }3233 const name = url.searchParams.get("name") ?? "Name";34 const title = url.searchParams.get("title") ?? "Title";35 const image = url.searchParams.get("image");36 const cache = await caches.open("cloudflare-ogp");37 const cacheKey = new Request(url.toString());38 const cachedResponse = await cache.match(cacheKey);39 if (cachedResponse) {40 return cachedResponse;41 }4243 const [imageType, imageBuffer] = await convertImage(image);4445 const ogpNode = (46 <div47 style={{48 display: "flex",49 flexDirection: "column",50 width: "100%",51 height: "100%",52 padding: "16px 24px",53 overflow: "hidden",54 fontFamily: "NotoSansJP",55 }}56 >57 <div58 style={{59 display: "flex",60 flexDirection: "column",61 height: "100%",62 border: "solid 16px #0044FF",63 borderRadius: "24px",64 boxSizing: "border-box",65 background: "linear-gradient(to bottom right, #ffffff, #d3eef9)",66 }}67 >68 <div69 style={{70 display: "flex",71 flex: 1,72 }}73 >74 {image && (75 <img76 style={{77 borderRadius: "100%",78 padding: "8px",79 marginRight: "16px",80 position: "absolute",81 opacity: 0.4,82 }}83 width={480}84 height={480}85 src={86 imageBuffer87 ? `data:${imageType};base64,${btoa(88 Array.from(new Uint8Array(imageBuffer))89 .map((v) => String.fromCharCode(v))90 .join("")91 )}`92 : undefined93 }94 alt=""95 />96 )}97 <h198 style={{99 display: "block",100 flex: 1,101 fontSize: 72,102 alignItems: "center",103 justifyContent: "center",104 padding: "0 42px",105 wordBreak: "break-all",106 textOverflow: "ellipsis",107 lineClamp: 4,108 lineHeight: "64px",109 }}110 >111 {title}112 </h1>113 </div>114 <div115 style={{116 width: "100%",117 justifyContent: "flex-end",118 fontSize: 48,119 padding: "0 32px 32px 0",120 color: "#CC3344",121 }}122 >123 {name}124 </div>125 </div>126 </div>127 );128 const png = await createOGP(ogpNode, {129 ctx,130 scale: 0.7,131 width: 1200,132 height: 630,133 fonts: [134 "Noto Sans",135 "Noto Sans Math",136 "Noto Sans Symbols",137 // 'Noto Sans Symbols 2',138 "Noto Sans JP",139 // 'Noto Sans KR',140 // 'Noto Sans SC',141 // 'Noto Sans TC',142 // 'Noto Sans HK',143 // 'Noto Sans Thai',144 // 'Noto Sans Bengali',145 // 'Noto Sans Arabic',146 // 'Noto Sans Tamil',147 // 'Noto Sans Malayalam',148 // 'Noto Sans Hebrew',149 // 'Noto Sans Telugu',150 // 'Noto Sans Devanagari',151 // 'Noto Sans Kannada',152 ],153 emojis: [154 {155 url: "https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/",156 },157 {158 url: "https://openmoji.org/data/color/svg/",159 },160 ],161 });162 const response = new Response(png, {163 headers: {164 "Content-Type": "image/png",165 "Cache-Control": "public, max-age=31536000, immutable",166 date: new Date().toUTCString(),167 },168 cf: {169 cacheEverything: true,170 cacheTtl: 31536000,171 },172 });173 ctx.waitUntil(cache.put(cacheKey, response.clone()));174 return response;175};176177export default {178 fetch: outputOGP,179};
パラメータ
| parameter | description |
|---|---|
| title | The title of the page |
| name | The name of the person or organization |
| image | URL of the image to be used as the Open Graph image (WebP and AVIF images are automatically converted to PNG format for processing) |
実行結果
デプロイした場合は、そのドメイン名に置き換えてください。
まとめ
Cloudflare Workers を使用して OGP を生成する方法について説明しました。無料プランで利用する場合は、10 万回/日まで実行可能です。また、カスタムドメインを使って CDN のキャッシュが使えるようにしておけば、ほぼ無制限で利用できる状況になります。