1本のランから3枚のOG画像を出す ― satori の制約と、写真に重ねる透過PNG
⚠ NOTICE — この記事の文体について
本記事の文章は 生成 AI(Claude)の協力のもとで執筆しています。実装内容・コード・設計判断はすべて筆者本人のものですが、説明の構成や言い回しに AI 特有のクセが残っている可能性があります。バイブコーディング(AI ペアプロ駆動開発)の実践レポートとして読んでもらえると幸いです。
はじめに
朝走り終わると、同じ1本のランから3枚の PNG ができる。
1枚目は X に流れる横長のカード。2枚目は背景を塗っていない透過 PNG で、自分で撮ったシューズの写真の上に iPhone で重ねる。3枚目は Instagram ストーリーにそのまま上げられる縦1枚。データは全部同じで、行き先だけが違う。
ImageResponse で JSX から PNG を作る話そのものは最初の記事に書いた。ここではその先を書く。CSS のつもりで書いたものが無視されて、そのたびに何を諦めて何で代替したか。
3つのレイアウトと、それぞれの行き先
寸法は3つとも別だ。
| 種別 | 寸法 | 背景 | 行き先 |
|---|---|---|---|
| horizontal | 1200×675(ラップがある日は 1200×980) | 純黒 | X のカード、サイトの OG |
| vertical | 1280×1920 | 塗らない(透過) | 自分の写真に重ねる |
| story | 1080×1920 | 純黒 | Instagram ストーリー |
横カードはこう出る。7月26日、12.11km を 3:47/km。ラップが取れた日なので下に 1km ごとのペース推移が付き、高さが 675 から 980 に伸びている。

分岐そのものは薄い。エントリで orientation を見て、レイアウト関数を選ぶだけにしてある。
// lib/og/today-run.tsx
export function buildTodayRunElement(
run: TodaysRun,
options: RenderOptions = {},
): React.ReactElement {
const { orientation } = options;
const isVertical = orientation === "vertical";
// story も縦画像なので休養日は縦レイアウトの REST 画像を出す。
if (!run.hasRun) return renderRestElement(options.asOfIso, isVertical || orientation === "story");
if (isVertical) return renderVerticalRunElement(run, options);
if (orientation === "story") return renderStoryRunElement(run, options);
return renderRunElement(run, options);
}
問題は「なぜ3つ要るのか」のほうだ。自分は最初、横と縦の 2 つで足りると思っていた。
透過オーバーレイと自立した1枚は、同じ画像では兼ねられない。透過 PNG を X に貼れば、閲覧側のテーマ次第で下地が白にも黒にもなる。白背景に白文字が乗れば何も見えない。逆に、黒く塗った story 画像を自分の写真に重ねたら写真が全部隠れる。背景を塗るか塗らないかは排他で、後から分岐でどうにかできる種類の違いではなかった。
同じランの story 版がこれ。横カードと同じ部品を縦に組み直してあって、こちらは背景を黒で塗ってある。

story を 9:16 ちょうどにしたのは Instagram の都合だ。他の比率で上げるとアプリが上下に黒帯を足すか中央だけ切り抜くかして、意図した構図で出てこない。一方で透過オーバーレイのほうは 2:3(1280×1920)にしてある。こちらは Instagram に直接渡すのではなく写真アプリで合成するので、ネイティブ比に縛られない。当時は指標を4カラム並べていて 1080 幅だと横が詰まったため、幅のほうを広げた。いまは3カラムに減ったが、寸法は戻していない。
保存はプレビュー画面で毎朝やっている。/api/og/today-run にブラウザで直接アクセスすると3枚並んだ HTML が返り、Web Share API 経由で写真アプリに渡せる。ここで一度つまずいたのが読み込み順で、3枚を同時に読むと縦(1280×1920)の描画が Node の単一スレッドを塞ぎ、真っ先に見たい横カードがその後ろで待たされていた。実測で横単体 0.28 秒が同時読み込みだと 0.77 秒。いまは横を出し切ってから縦、story と順に読ませている。
satori にできないこと
先に諦めたものを並べておく。
-webkit-text-stroke は実描画されない。 参考にしていたデザインが中空のアウトライン文字だったので、color: transparent と stroke で組もうとした。fill だけが出る。プロパティ名自体はバンドルに存在するのに描画が未実装で、書き方を4パターン変えて試した末に諦めた。中空文字が要るなら satori では作れないと早めに決めて、ベタ塗り前提に切り替えたほうがいい。自分は 4 回とも「次の書き方なら通るかもしれない」と思って試していた。
blur と filter が効かない。 filter: blur() も backdrop-filter も無視される。ネオンの発光をぼかしで作る手が最初から無い。
合成ボールドが無い。 既定フォントは Geist の Regular 単一ウェイトなので、fontWeight: 900 を書いても何も起きない。ブラウザなら勝手に太らせてくれるところを、satori はやらない。太いウェイトが要るなら、そのウェイトのフォントファイルを自分で渡す。
効くものもある。textShadow は描かれる。この記事に出てくる小細工はほぼ全部、textShadow と多重描画の 2 つだけで組んでいる。
なお mix-blend-mode: screen も効かない。走行軌跡ページで重なりの密度を出すのに Web 側では screen を使っていて、OG 画像だけ別の手で代替した。そちらは軌跡アートの記事に書いた。
白いシューズの上での視認性
透過オーバーレイで最初に出したのは、白文字にハローを付けただけのものだった。共有部品としてこう置いてある。
// lib/og/today-run-shared.tsx
export const TEXT_HALO = "0 2px 18px rgba(0,0,0,0.72)";
背景を自分で塗る横カードと story はこれで足りる。透過版は足りなかった。夜明けの暗い写真なら読めるが、明るい下地に載せると小さい mono 文字が沈む。
そこで透過版専用のハローを作った。輪郭を締める近距離の濃い影と、広い落ち影の2層を重ねている。
// lib/og/today-run-vertical.tsx
const OVERLAY_HALO =
"0 0 8px rgba(0,0,0,0.95), 0 0 3px rgba(0,0,0,0.9), 0 4px 32px rgba(0,0,0,0.85)";
これで一段よくなって、まだ足りなかった。実際に白いシューズを撮って重ねてみると、スタッツの数字がまだ読みにくい。
普通ならここで文字を太くする。前の節のとおり、それができない。フォントは Regular しか持っておらず、satori は太らせてくれない。なので下地の明るさに依存しない黒地を敷いて、その上に白文字を置く方向に切り替えた。
/**
* 文字の背面に敷く半透明の黒プレート。ハロー強化 (2026-07-17) だけでは白いシューズ等の
* 明るい下地で小さい mono 文字がまだ沈む、との実機フィードバックを受けて追加。
* フォントが Regular ウェイトのみ (satori は合成ボールド不可) のため「太くする」は
* 選べず、下地の明るさに依存しない黒地で読ませる方式にした。
*/
const SCRIM_BG = "rgba(0,0,0,0.6)";
日付タグ、天気ピル、連続日数バッジ、下部のスタッツ帯。文字が固まっている場所に角丸のプレートを敷いて回った。透過 PNG なのに黒い板が何枚も載っているのは妥協の跡だが、読めないよりはいい。
実際に白いシューズの写真へ重ねると、こうなる。

上の黒い帯が SCRIM_BG だ。灰色のタイルの床と白いシューズという、いちばん文字が沈む条件でも、プレートの上に載った文字は読める。
巨大な距離の数字(フォントサイズ 300)だけはプレートを敷いていない。あれは面積があるのでハローだけで勝てる。この合成でも 12.11 は白文字のままシューズの上に乗っているが、読むのに困らない。沈むのは小さい文字のほうだ。
blur が無いなら3回引く
ルートのネオン発光は、同じ線を太さと不透明度を変えて3回引いて作っている。外側の太い半透明がぼやけた発光に見え、その上の細い本線が芯になる。
// lib/og/og-glow.ts
export function renderGlowStroke(options: GlowStrokeOptions): React.ReactElement[] {
const { geometry, layers, color = SIGNAL, keyPrefix = "" } = options;
const isPolyline = "points" in geometry;
return layers.map((layer, i) =>
createElement(isPolyline ? "polyline" : "path", {
key: `${keyPrefix}${i}`,
...geometry,
fill: "none",
stroke: color,
strokeWidth: layer.width,
strokeLinecap: "round",
strokeLinejoin: "round",
opacity: layer.opacity,
}),
);
}
呼ぶ側は層の定義を渡すだけだ。不透明度は 0.16 / 0.45 / 1 で固定してある。
// lib/og/today-run-shared.tsx (renderGlowRouteSVG より)
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
{renderGlowStroke({
geometry: { points: pts },
layers: [
{ width: outerW, opacity: 0.16 },
{ width: midW, opacity: 0.45 },
{ width: coreW, opacity: 1 },
],
})}
{/* スタート地点: グロー付きの発光ドット */}
<circle cx={sx} cy={sy} r={dotGlowR} fill={`rgba(${SIGNAL_RGB},0.18)`} />
<circle cx={sx} cy={sy} r={dotR} fill={SIGNAL} />
</svg>
);
線の太さだけはレイアウトごとに変えている。横カードは 400×260 の小窓に描くので [16, 9, 5]、透過オーバーレイは [18, 11, 6]、story は 920×720 の大きなステージなので [20, 12, 6]。
両端で失敗してからこの値に落ち着いた。初期の透過版はストローク幅 44 の極太で描いていて、往復路が1つの塊に潰れていた。走った形が何も読めない、ただの帯が真ん中にあった。逆に横カードの細さをそのまま story に持っていくと、1080 幅の中で線が痩せて発光に見えない。
同じことは軌跡ページの OG 画像でもやっている。あちらは polyline ではなく path なので、renderGlowStroke は points と d のどちらでも受けられるようにした。
日本語を78グリフに絞って同梱する
見出しの数字は Alfa Slab One、ラベルは Geist Mono、日本語は Noto Sans JP。TTF を3つ同梱している。
読み込みは process.cwd() 起点の readFileSync だ。
// lib/og/today-run-shared.tsx
export const DISPLAY_FONT_FAMILY = "Alfa Slab One";
export const DISPLAY_FONT_DATA = readFileSync(
join(process.cwd(), "lib/og/fonts/AlfaSlabOne-Regular.ttf"),
);
import.meta.url 起点にしていない理由は、Turbopack のビルドでアセット解決が読めないから。ローカルでは通ってデプロイ後だけフォントが落ちる、という壊れ方をされると原因を掴みにくい。あわせて、デプロイ成果物にファイルが同梱されることを Next の設定側で明示している。
// next.config.ts
outputFileTracingIncludes: {
"/api/og/today-run": ["./lib/og/fonts/**"],
"/api/cron/post-run": ["./lib/og/fonts/**"],
},
file tracing はローカルで完全には検証できない。ここは本番で実物を見るまで完了扱いにしない場所だと思っている。
日本語フォントはそのままでは同梱できない。Noto Sans JP のフル版は MB 単位で、この用途には重すぎる。OG 画像に出る日本語は決まりきったラベルと天気の語彙しかないので、使う文字だけのサブセットを生成している。現物は 48,480 バイト、78 グリフ。
// scripts/generate-og-jp-subset.mjs
const GLYPHS =
" ./%0123456789:ADYkm℃のイスタットパプペムラルワ・ー下今区均天寒平強心快拍日晴暑暴曇最月極気氷温湿灼点熱目累続計走速連酷間降雨雪雷霧風度";
読みにくい文字列だが、これが文字集合の単一ソースだ。ここに足して再生成すれば新しい日本語が出せるし、足さずにラベルだけ増やすと事故る。サブセットに無いグリフは豆腐(□)にすらならず、単に描画されない。文字が抜けた画像がそのまま出るので、気づきにくい壊れ方をする。
もう1つ、カスタムフォントを渡すこと自体に副作用がある。satori に自前のフォントを1つでも渡すと、同梱の既定フォントと monospace などの generic family の解決が効かなくなる。なのでレイアウト側では例外なくファミリ名を明示している。日本語グリフについては、mono を指定したまま読み込み済みの別フォントへフォールバックさせる形にした。
透過オーバーレイでは、旧版が距離を4カラムの1つとして DIST ラベル付きで小さく扱っていた。これを巨大な数字に昇格させたとき、ラベルごと消している。数字と km があれば距離だと分かる。他のラベルに合わせて日本語にするなら「距離」の2文字をサブセットに足すことになるが、その必要もなくなった。
走った時の天気と、画像を開いた時の天気
7月26日、同じ1本のランに3つの天気が存在した。
この記事に貼った3枚を見比べてほしい。どれも 12.11km・3:47/km・START 06:21 の同じランなのに、横カードは 27℃ の湿度 79%、story は 28℃ の 72%、シューズに重ねた透過版は 33℃ の 52% と出ている。OG 画像が生成のたびに現在天気を取りに行っていたので、焼かれるのは画像を開いた時刻の天気だった。3枚を保存した時刻がそれぞれ違う、というだけの理由で数字が動いている。
記録として成立していない。走行ログに「その日の気温」を載せる意味は、走った時間帯の条件が残ることだ。夕方の気温を朝のランのカードに刷るなら、最初から載せないほうがましだった。
OpenWeatherMap の Current API は過去を返さない。History API は有料。つまり後から正しい値を取り直す道が無いので、正しい値が手に入る瞬間に保存しておくしかない。
その瞬間は Strava の Webhook が届く時だ。時計が同期した直後に POST が飛んでくるので、走り終えてから数十秒しか経っていない。
// app/api/strava/webhook/route.ts
// ここが「走った時の天気」を得られる唯一の正確な地点 (webhook はアップロード直後に届く)。
if (weather.ok && weather.snippet) {
await saveRunWeather(getJstIsoDate(new Date()), weather.snippet);
}
保存側で1つだけ工夫が要った。書き込み元が2経路ある。Webhook と、Webhook が落ちた日の保険である 07:00 の cron だ。後から来る cron の値は走行から時間が経っていて精度が落ちるので、先に書かれた値を守らないといけない。Redis の nx オプションで先勝ちにした。
// lib/weather-store.ts
export async function saveRunWeather(jstDate: string, snippet: WeatherSnippet): Promise<void> {
const record: StoredRunWeather = {
date: jstDate,
snippet,
recordedAt: new Date().toISOString(),
};
const key = runWeatherKey(jstDate);
try {
const redis = getRedis();
if (!redis) {
if (!memoryStore.has(key)) memoryStore.set(key, record);
return;
}
await redis.set(key, record, { nx: true });
} catch (e) {
reportError(`saveRunWeather failed (${jstDate})`, e, { source: "weather-store:save" });
}
}
取得時刻(recordedAt)も一緒に残している。いまは使っていないが、後から「この値はいつ取ったのか」を確認できないと、また同じ疑いを持ったときに調べようがない。
読む側は、保存があればそれを使い、無い日だけ現在天気に落とす。
// app/api/og/today-run/route.tsx
hasStreakStats
? coalesce("og:weather", async () => {
const stored = await getRunWeather(runJstDate);
return stored
? { ok: true, snippet: stored }
: fetchWeather({ revalidateSeconds: 600 });
})
: null,
これで X の本文も Slack のカードも3枚の画像も、同じ1つの値を読むようになった。索引(Sorted Set)は持っていない。参照は常に特定の1日で、範囲取得をする用途が無いためだ。
保存に失敗しても投稿と画像生成は止めない。天気は画像の主役ではないので、落ちたら天気行を消して残りを出す。
ルートの置き場所
透過オーバーレイで最後まで残った問題が、GPS ルートをどこに置くかだった。
当初のステージは 860×900 で、1920 の縦のうち約1000ピクセルを占めていた。画面のどまんなかを縦に貫いていたことになる。
下地の写真の被写体は、ほぼ必ず中央に来る。シューズを撮れば真ん中にシューズがある。この配置だとどう転んでも重なる。実際、白いシューズの上でネオンの線が完全に埋もれた画像を見た。線も読めないし、シューズも見えない。
ステージを 560×520 に縮めて、スタッツ帯の直上に寄せた。中央の 25% 前後を下地に明け渡す形にしている。
ルートを包む div の style と、そこに残したコメントはこうなっている。
// lib/og/today-run-vertical.tsx
// ネオングロー・ルート。flex: auto で余白を吸収しつつ **下寄せ** (flex-end) にして
// スタッツ帯の直上に置く。中央を空けるのが目的なので center には戻さないこと。
const routeStageStyle = {
display: "flex",
width: "100%",
flex: "auto",
justifyContent: "center",
alignItems: "flex-end",
};
alignItems: "flex-end" にコメントを付けたのは、レイアウトを触るときに中央揃えへ戻したくなるのが目に見えているから。見た目のバランスだけ考えれば center のほうが収まりがいい。だが今回は収まりが悪いほうが正しい。
それでも重なる日はある。上のシューズの合成がまさにそれで、下寄せにしたルートがソールの白い部分に落ちている。線は残っているが、黒い下地のときほど発光して見えない。写真の構図は毎回違うので、レイアウトの固定値では吸収しきれない。逃げ道としてクエリでルートごと落とせるようにした。
// app/api/og/today-run/route.tsx
// `?route=0` で GPS ルートを落とす (縦オーバーレイのみ有効)。写真の被写体に線が
// 重なる日の逃げ道で、既定は描画する。
const showRoute = url.searchParams.get("route") !== "0";
横カードと story はこのオプションを見ない。あちらはルートが構図の主役で、消すとレイアウトが崩れるからだ。同じオプションを全レイアウトに配るより、効く場所を型のコメントに書いて絞ったほうが後で困らない。
まとめ
satori 向けに書いたことは、ほとんどが引き算だった。中空文字が作れないのでベタ塗りにして、blur が無いので同じ線を3回引いて、太字が作れないので黒い板を敷いた。日本語も全部は載せられないから78文字に絞った。ブラウザなら CSS 一行で済む話ばかりだ。
面倒ではある。ただ、悪くない制約だとも思っている。使える表現が少ないぶん、画像の見た目が勝手に散らからない。
天気の件だけは毛色が違う。あれは自分の設計ミスだ。表示のたびに取ってくる、が正しくない種類のデータがあると、事故が起きるまで気づかなかった。走った時刻の気温は、走った時刻にしか取れない。
シリーズナビ
この記事を書いた人

気に入ったら、次の記事もメールで。月1〜2通だけ。
Next Action / 次の動き
走行データは、こちらから。
読了ありがとうございます。気が向いたら、生データもどうぞ。