SUZ-LAB

カバーも 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 ファイルに宣言した。生成スクリプトはこの一覧を読み、コンポーネントは srcpublic/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.tsBRAND が唯一の出所で、globals.cssapps/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/webapps/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.tsassetSrc で読む。

ハマったところ・判断

  • 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 をプロダクションに載せる素直な形だった。
#AI#Gemini#Nano Banana#画像生成#ブランド#単一の出所#Next.js

同じ柱のほかのログ

この柱の Roadmap を見る