カバーも AI に描かせる — グラレコ生成パイプラインを content 一本に畳んだ
各ページ・各記事のカバー画像を、Gemini の画像モデル(Nano Banana Pro)で生成するパイプラインの記録。「AI を使い倒す」を、制作プロセスそのもので体現する一手。生成する全ビジュアルとそのプロンプトを content/assets.ts 一本の唯一の出所にし、ブランドのビジュアル言語(deep navy + electric-blue)は asset-config.ts に集約。実験ログのカバーは NO_TEXT ルールをあえて破ってグラレコ(手書き文字・アイコン・矢印)で記事を要約する例外にした。生成物はコミットしてビルドを再現可能にし、コンテンツハッシュでキャッシュを無効化する——という設計判断を、実際のコードで残します。
何が問題だったか
サイトの各ページ・各記事には、視覚の入口としてカバー画像が要る。選択肢は 3 つあった。
- 手で描く / デザイナーに頼む … 記事が増えるたびに現実的でない。
- ストック画像 … ブランドの世界観(deep navy + 発光するブルー)が出せず、寄せ集めに見える。
- AI に生成させる … ブランドを言語化してプロンプトに落とせば、量産しても世界観が揃う。
SUZ-LAB の行動原則は「AI を使い倒す」。ならばカバーこそ 制作プロセス自体で AI を使い倒す実例にできる。3 つ目を選んだ。
ただし素朴に「都度 AI に投げる」だけだと破綻する。プロンプトが散らばって世界観がぶれ、毎ビルドで API を叩けば再現性もコストも危うい。生成物とプロンプトを一元管理し、ビルドを再現可能にする枠組みが要る。
やってみたこと
1. 唯一の出所 — content/assets.ts
サイトが使う AI 生成ビジュアルを、それを生む プロンプトごと 1 ファイルに宣言した。生成スクリプトはこの一覧を読み、コンポーネントは src(public/assets/ 下のファイル名)で参照する。
// content/assets.ts
export type Asset = {
type: "image" | "video";
src: string; // public/assets/ 下のファイル名(dedupe キーも兼ねる)
prompt: string;
style?: string; // このアセットだけ IMAGE_STYLE を上書き(例: ログのグラレコ)
};
export const ASSETS: Asset[] = [
{ type: "image", src: "home.png", style: GRAPHIC_RECORDING_STYLE, prompt: "Wide horizontal sketchnote (グラレコ) …" },
// …各ページ・各ログのカバー
];
src を dedupe キーにしているので、同じファイル名を二度宣言しても 1 回だけ生成される。プロンプト(=生産の関心事)はページのコピー(vmost.ts 等)から分離し、ここに集約した。
2. ブランドのビジュアル言語 — asset-config.ts
「どう見えるか」はここに集約する。ただし意図の出所は DESIGN.md で、このファイルはそのルールを具体的なプロンプト断片に翻訳するだけ、という関係にしている。
// asset-config.ts
const BRAND_LOOK =
`deep navy (${BRAND.navy}) cinematic background, electric-blue accent glow, ` +
"soft gradients, generous dark negative space for overlaid text, minimal, elegant, high detail";
// DESIGN.md の禁止事項。生成フレームからは文字を追い出し、実テキストは HTML 側が持つ
const NO_TEXT = "no text, no letters, no words, no logos, no watermark";
export const IMAGE_MODEL = process.env.IMAGE_MODEL ?? "gemini-3-pro-image"; // Nano Banana Pro
export const IMAGE_ASPECT_RATIO = "16:9";
export const IMAGE_SIZE = "2K";
export const IMAGE_STYLE = `${BRAND_LOOK}, ${NO_TEXT}, 16:9`;
配色は content/site-config.ts の BRAND が唯一の出所で、globals.css や apps/video のテーマとも一致させている——web と video のメディアが同じブランドに読めるようにするためだ。
3. 実験ログのカバーは、あえて NO_TEXT を破る
ここが一番の設計判断だった。通常のカバーは NO_TEXT(フレーム内に文字を入れない。実テキストは HTML 側が持つ)を守る。ところが実験ログのカバーだけは、グラレコ(sketchnote)=手書き文字・アイコン・矢印で記事を要約するものなので、文字が入るのが本質だ。
そこで GRAPHIC_RECORDING_STYLE という意図的な例外を用意し、ログのアセットだけ style で上書きする。ブランドは崩さない——白+electric-blue の「マーカー」を deep navy に描く、光るライトボードのような見た目に固定した。
export const GRAPHIC_RECORDING_STYLE =
`graphic recording / sketchnote style: hand-drawn doodles, simple icons, ` +
`arrows and connectors, boxes and speech bubbles, energetic marker strokes, ` +
`a few short hand-lettered keywords written in NATURAL, CORRECT JAPANESE ` +
`(kanji, hiragana, katakana) — avoid English words and avoid garbled or fake characters; ` +
`drawn in white and electric-blue ink on a deep navy (${BRAND.navy}) background …`;
4. 生成ループは共有パッケージ、スクリプトは素材を渡すだけ
生成の中身(Gemini クライアント・生成ループ)は @suz-lab/gemini-assets に切り出し、apps/web と apps/video で共有する。web 側のスクリプトは、宣言済みアセット(ASSETS)と 16:9 の設定を渡すだけの薄い口だ。
// scripts/generate-images.ts
await generateImages({
ai: createClient(),
jobs: uniqueAssets(ASSETS, "image"),
model: IMAGE_MODEL,
aspectRatio: IMAGE_ASPECT_RATIO,
imageSize: IMAGE_SIZE,
defaultStyle: IMAGE_STYLE, // per-asset の style 上書き(グラレコ)は runner が尊重
outDir: join(appDir, "public", ASSETS_DIR),
force: process.argv.includes("--force"),
});
既に存在するファイルはスキップし、--force で全再生成する。生成後に続けてハッシュ更新(次項)を走らせる。
5. 生成物はコミットし、コンテンツハッシュでキャッシュ無効化
生成した画像は public/assets/ にコミットする。これでビルドは再現可能になり、毎レンダーで Gemini API を叩かずに済む(apps/video と同じ方針)。
ただしファイル名は安定(home.png)なので、中身だけ差し替えるとブラウザ / CDN / Next の画像最適化キャッシュに古い版が残る。そこで各アセットの内容ハッシュを asset-hashes.json に書き出し、URL に ?v=<hash> を付けてバイト列に追従させる。
// scripts/hash-assets.ts(抜粋)
for (const name of readdirSync(assetsDir).sort()) {
if (name.startsWith(".")) continue;
const bytes = readFileSync(join(assetsDir, name));
hashes[name] = createHash("sha256").update(bytes).digest("hex").slice(0, 8);
}
writeFileSync(manifestPath, `${JSON.stringify(hashes, null, 2)}\n`);
マニフェストはコミットし、ビルドステップでも再生成するので、常に出荷するバイト列と一致する。消費側は lib/asset-url.ts の assetSrc で読む。
ハマったところ・判断
- Imagen(旧 API)ではなく
generateContent+imageConfig。 トップ品質の Gemini 画像モデルは deprecated な Imagen 経由ではなく、generateContent側で使う。ここは共有 runner 側に寄せた。 - グラレコの手書き文字を「正しい日本語」で出す。 初期は手書き文字が英語や崩れた偽文字になりがちだった。プロンプトで「Latin の固有名詞(SUZ-LAB / VMOST / Gemini 等)はラテン文字のまま、それ以外の手書きラベルはすべて自然で正しい日本語」と明示的に指定して撃ち分けている。
- NO_TEXT とグラレコの矛盾を、例外として明文化する。 「文字を入れない」ルールと「手書き文字で要約する」ログカバーは真っ向から矛盾する。隠して分岐させるのではなく、
GRAPHIC_RECORDING_STYLEという名前付きの例外にし、コメントで「なぜ破るか(グラレコとは手書き文字そのものだから)」を残した。 - 再現性を取るか、鮮度を取るか。 毎ビルド生成なら常に最新だが、API 依存でビルドが不安定・高コストになる。生成物をコミットして再現性を取り、更新はハッシュ付き URL で確実に反映する側に倒した。
検証
GEMINI_API_KEY=… pnpm --filter @suz-lab/web imagesで全カバーが生成され、public/assets/に落ちること。既存ファイルはスキップ、--forceで再生成されること。- ログカバーがグラレコ(白+ブルーの手書き、日本語ラベル)で、他ページのカバーが
NO_TEXTのシネマティックな見た目で出ること。 - 画像を差し替えると
asset-hashes.jsonの該当ハッシュが変わり、?v=が更新されて即反映されること。 - web と
apps/videoのメディアが同じブランド(deep navy + electric-blue)に読めること。
学び
- 「AI を使い倒す」は、成果物より制作プロセスに出る。 カバーを AI に描かせること自体が、行動原則の実例になった。ブランドを言語化してプロンプトに落とせば、量産しても世界観が揃う。
- プロンプトは「唯一の出所」の資産として扱う。
content/assets.tsに src = プロンプトで宣言し、意図(DESIGN.md)と実装(asset-config.ts)を分けたことで、世界観を変えるときも 1 か所を直して--forceで焼き直せる。 - ルールを破るなら、名前を付けて理由を残す。 NO_TEXT を破るログカバーは、こっそり分岐させず
GRAPHIC_RECORDING_STYLEという例外に昇格させた。例外に名前と理由があると、後から読んでも「意図的な逸脱」だと分かる。 - 生成 AI の出力は、コミットして初めて再現可能になる。 ビルドを API に依存させないために生成物を版管理し、ファイル名固定の弱点(キャッシュ)はコンテンツハッシュで塞ぐ。生成と配信を分離するのが、生成 AI をプロダクションに載せる素直な形だった。




