はじめに
note や SNS に投稿するためのアイキャッチ画像を作りたいだけなら、必ずしも画像生成AIつきの月額サービスを追加契約する必要はありません。
たとえば、普段は Claude で文章作成や記事構成を考えている人が、投稿用の画像だけを必要なときに生成したい場合があります。
Claude は画像生成プロンプトを作ることはできますが、一般的な画像生成ツールのように写真やイラストを直接生成する用途ではありません。
そこで考えられるのが、次のような分業です。
Claude:
記事内容の整理
アイキャッチ画像のコンセプト作成
画像生成プロンプト作成
失敗した画像への改善指示作成
画像生成API:
実際の画像生成
自作UI:
プロンプト、モデル、サイズ、参照画像、履歴を扱う入力補助画面
この記事では、SNS投稿用や note のアイキャッチ画像作成を題材に、画像生成API向けの小さな入力補助UIを HTML / CSS / JavaScript で作ります。
既存のAIサービスや画像生成APIを補助するための小さな画面を作る、というケーススタディです。
完成版はこちらです。
👉 CodePen:
Chrome ブラウザーで HTML ファイルの表示を確認したい場合、Web Maker を導入する選択肢があります。
想定読者
この記事は、次のような人を想定しています。
- Claude など、画像生成機能を持たない文章AIを主に使っている人
- note / Qiita / Zenn / X / Threads などに投稿している人
- 投稿用アイキャッチ画像をたまに作りたい人
- 画像生成AIの月額契約までは迷っている人
- fal.ai などの画像生成APIを試したい人
- Chrome拡張やサイドパネルUIに興味がある人
- HTML / CSS / JavaScript のUI練習題材を探している人
なぜ作るのか
今回の主題は「画像生成APIを使おう」ではありません。
むしろ、問いは次です。
SNS投稿用の画像を作るためだけに、
画像生成AIつきの月額サービスを追加契約する必要はあるのか?
画像生成を毎日大量に行うなら、専用サービスの月額契約は便利です。
しかし、note のアイキャッチ画像や SNS 投稿用画像のように、必要なときに数枚試して1枚使えればよい用途もあります。
このような用途では、次のような構成が現実的です。
文章作成:
Claude などの文章AI
画像生成:
fal.ai などの画像生成API
入力補助:
自分用の小さなWeb UI
既存サービスの入力画面を補助するための、小さなアプリです。
選択肢を比較する
画像生成のための選択肢を大きく分けると、次のようになります。
| 選択肢 | 向いている用途 | 注意点 |
|---|---|---|
| 画像生成つきAIサービス | 対話的な調整、本命画像の作成、継続的な画像制作 | 月額費用が用途に対して重い場合がある |
| Claudeなど文章AI + 画像生成API | 文章AIは使っていて、画像だけ必要なときに生成したい | APIキー管理と簡単な実装が必要 |
| 既存の無料画像生成サービス | 少量の試用、品質確認 | 上限や履歴管理に制約がある |
| AIエージェント + 画像/動画API | 複数工程の自動化、連続加工、動画生成 | 設計とコストが重くなりやすい |
APIのほうが常に安い、という話ではありません。
ただし、利用頻度が低い場合や、SNS投稿用の単発生成が中心の場合は、月額契約の前に API 利用と小さな入力UIで十分かどうかを試す価値があります。
今回作るもの
今回作るのは、画像生成サンドボックス風の小さなUIです。
固定フォーム
├── モード切替
│ ├── テキストから画像
│ └── 画像から画像
├── モデル選択
├── サイズ選択
└── 入力行
├── 参照画像サムネイル
├── プロンプト
└── 生成ボタン
履歴エリア
└── 生成中カード / 完成カード
画面の主な機能は次です。
- 上部に入力フォームを固定する
- プロンプト送信で履歴カードを追加する
- 生成中カードを先に表示する
- setTimeoutで疑似的に生成完了にする
- テキストから画像 / 画像から画像を切り替える
- モードごとにモデル選択肢を切り替える
- 画像から画像モードだけ参照画像を選べるようにする
- 参照画像を小さなサムネイルとして表示する
この記事では、まずAPIなしでUIを作ります。
そのあと、API接続、ポーリング、履歴ストレージの考え方を整理します。
1. 上部に入力フォームを固定する
まずは、入力フォームを画面上部に固定します。
<header class="generator-header">
<form class="generator-form">
<input placeholder="プロンプトを入力">
<button>生成</button>
</form>
</header>
<main class="gallery">
<p>ここに生成履歴を表示します</p>
</main>
body {
margin: 0;
padding-top: 120px;
font-family: system-ui, sans-serif;
background: #f5f5f5;
}
.generator-header {
position: fixed;
inset: 0 0 auto;
z-index: 10;
padding: 16px;
background: white;
border-bottom: 1px solid #ddd;
}
.generator-form {
max-width: 960px;
margin: 0 auto;
display: flex;
gap: 8px;
}
.gallery {
max-width: 960px;
margin: 0 auto;
padding: 16px;
}
ポイントは、position: fixed と body 側の padding-top です。
フォームを固定すると、その下のコンテンツがフォームの裏に隠れます。そのため、body 側に上余白を入れます。
2. submitイベントで履歴カードを追加する
次に、フォーム送信時に履歴カードを追加します。
<form id="generatorForm">
<input id="promptInput" placeholder="プロンプトを入力">
<button>生成</button>
</form>
<main id="gallery"></main>
const form = document.querySelector("#generatorForm");
const promptInput = document.querySelector("#promptInput");
const gallery = document.querySelector("#gallery");
let count = 0;
form.addEventListener("submit", (event) => {
event.preventDefault();
const prompt = promptInput.value.trim();
if (!prompt) {
return;
}
count += 1;
const card = document.createElement("article");
card.textContent = `#${count}: ${prompt}`;
gallery.prepend(card);
promptInput.value = "";
});
gallery.prepend(card) を使うと、新しい履歴が上に追加されます。
画像生成UIでは、新しい生成結果を上に積むほうが見やすいことが多いです。
3. 生成中カードを先に表示する
画像生成では、結果がすぐ返るとは限りません。
そこで、先に「生成中カード」を表示します。
function createLoadingCard(prompt) {
const card = document.createElement("article");
card.className = "image-card is-loading";
card.innerHTML = `
<div class="image-placeholder">生成中...</div>
<p>${prompt}</p>
<span class="status">生成中</span>
`;
return card;
}
CSSは最小限ならこうです。
.image-card {
display: grid;
gap: 12px;
padding: 12px;
border: 1px solid #ddd;
border-radius: 16px;
background: white;
}
.image-placeholder {
aspect-ratio: 1 / 1;
display: grid;
place-items: center;
background: #eee;
border-radius: 12px;
}
.status {
width: fit-content;
padding: 4px 8px;
border-radius: 999px;
background: #fff7d6;
color: #806000;
font-size: 12px;
}
4. setTimeoutで疑似的に生成完了にする
最初から実APIにつなぐと、APIキー管理やポーリングの話が混ざります。
まずは setTimeout() で、3秒後に生成完了したことにします。
function completeCard(card) {
card.classList.remove("is-loading");
card.classList.add("is-complete");
const image = card.querySelector(".image-placeholder");
const status = card.querySelector(".status");
image.textContent = "IMAGE";
image.className = "generated-image";
status.textContent = "生成完了";
}
const card = createLoadingCard(prompt);
gallery.prepend(card);
setTimeout(() => {
completeCard(card);
}, 3000);
完成画像のダミー表示です。
.generated-image {
aspect-ratio: 1 / 1;
display: grid;
place-items: center;
background: linear-gradient(135deg, #c7d2fe, #fbcfe8);
border-radius: 12px;
}
この段階では、APIを呼んでいません。
後で、この setTimeout() の部分を fetch() やポーリングに置き換えます。
5. テキストから画像 / 画像から画像のモードを切り替える
次に、画像生成モードを追加します。
<label>
<input type="radio" name="mode" value="text-to-image" checked>
テキストから画像
</label>
<label>
<input type="radio" name="mode" value="image-to-image">
画像から画像
</label>
選択中のモードを取得します。
function getSelectedMode() {
return document.querySelector("input[name='mode']:checked").value;
}
送信時に、プロンプトだけでなくモードも扱います。
const mode = getSelectedMode();
const request = {
mode,
prompt
};
このあたりから、フォーム入力を直接バラバラに扱うのではなく、request オブジェクトとしてまとめると扱いやすくなります。
6. モードごとにモデル選択肢を切り替える
fal.ai のサンドボックス風に、モードごとにモデル候補を切り替えます。
<select id="modelSelect"></select>
const modelSelect = document.querySelector("#modelSelect");
const modelOptionsByMode = {
"text-to-image": [
{ value: "flux-schnell", label: "FLUX Schnell" },
{ value: "flux-dev", label: "FLUX Dev" }
],
"image-to-image": [
{ value: "flux-kontext", label: "FLUX Kontext" },
{ value: "image-edit-basic", label: "Image Edit Basic" }
]
};
select の中身を作り直します。
function renderModelOptions(mode) {
modelSelect.replaceChildren();
for (const option of modelOptionsByMode[mode]) {
const optionElement = document.createElement("option");
optionElement.value = option.value;
optionElement.textContent = option.label;
modelSelect.append(optionElement);
}
}
モード変更時に呼び出します。
const modeInputs = document.querySelectorAll("input[name='mode']");
for (const modeInput of modeInputs) {
modeInput.addEventListener("change", () => {
renderModelOptions(modeInput.value);
});
}
renderModelOptions("text-to-image");
replaceChildren() を使うと、既存の選択肢を一度消してから作り直せます。
7. 画像から画像モードだけ参照画像ボタンを表示する
画像から画像モードでは、参照画像が必要です。
ただし、標準の file input をそのまま見せると、画面が大きくなりがちです。
そこで、小さなサムネイルボタンとして扱います。
<label class="source-thumb-button" id="sourceThumbButton">
<input
id="sourceImageInput"
type="file"
accept="image/png, image/jpeg, image/webp"
>
<img id="sourcePreview" alt="参照画像プレビュー">
<span class="source-placeholder">+</span>
</label>
.source-thumb-button {
display: none;
width: 52px;
height: 52px;
border: 1px solid #ccc;
border-radius: 14px;
background: white;
overflow: hidden;
cursor: pointer;
place-items: center;
}
.is-image-to-image .source-thumb-button {
display: grid;
}
.source-thumb-button input {
display: none;
}
.source-thumb-button img {
width: 100%;
height: 100%;
object-fit: cover;
}
.source-thumb-button img:not([src]) {
display: none;
}
.source-placeholder {
font-size: 24px;
color: #777;
}
フォーム全体に is-image-to-image クラスがついているときだけ表示します。
function setupMode(mode) {
form.classList.toggle("is-text-to-image", mode === "text-to-image");
form.classList.toggle("is-image-to-image", mode === "image-to-image");
renderModelOptions(mode);
}
参照画像は、生成結果カードには表示しません。
今回の用途では、フォーム上で「参照画像を選択済みか」を確認できれば十分だからです。
8. URL.createObjectURLで参照画像をプレビューする
選択した画像を、送信前に小さく確認できるようにします。
const sourceImageInput = document.querySelector("#sourceImageInput");
const sourcePreview = document.querySelector("#sourcePreview");
const sourceThumbButton = document.querySelector("#sourceThumbButton");
let sourceImagePreviewUrl = "";
sourceImageInput.addEventListener("change", () => {
const file = sourceImageInput.files[0];
if (!file) {
return;
}
if (sourceImagePreviewUrl) {
URL.revokeObjectURL(sourceImagePreviewUrl);
}
sourceImagePreviewUrl = URL.createObjectURL(file);
sourcePreview.src = sourceImagePreviewUrl;
sourceThumbButton.classList.add("has-image");
});
画像を選択済みの場合、+ を消します。
.source-thumb-button.has-image .source-placeholder {
display: none;
}
注意点があります。
URL.createObjectURL() で作られる blob: URL は、ブラウザ内の一時URLです。
外部の画像生成APIが直接アクセスできるURLではありません。
APIに参照画像を送る場合は、画像ファイルを FormData で送るか、いったんアップロードして外部からアクセスできるURLにする必要があります。
9. requestオブジェクトとして入力内容をまとめる
フォームの値を、1回の生成リクエストとしてまとめます。
const modeLabels = {
"text-to-image": "テキストから画像",
"image-to-image": "画像から画像"
};
const sizeLabels = {
square: "正方形 1:1",
landscape: "横長 16:9",
portrait: "縦長 9:16"
};
const request = {
id: imageCount,
mode,
modeLabel: modeLabels[mode],
prompt,
model,
modelLabel,
size,
sizeLabel: sizeLabels[size],
status: "loading"
};
if (mode === "image-to-image") {
request.sourceImage = {
file: sourceImageInput.files[0]
};
}
この形にしておくと、次の処理を同じ情報から作れます。
- 履歴カード表示
- API送信
- ローカル履歴保存
- エラー表示
DOMの状態を直接あちこちで参照し続けるより、いったん request オブジェクトにまとめるほうが見通しがよくなります。
10. fetch + FormDataで参照画像を送る考え方
画像から画像モードでは、参照画像ファイルを送る必要があります。
画像ファイルは JSON ではなく、FormData で送るのが基本です。
const formData = new FormData();
formData.append("image", request.sourceImage.file);
formData.append("prompt", request.prompt);
formData.append("model", request.model);
formData.append("size", request.size);
await fetch("/api/generate-image-to-image", {
method: "POST",
body: formData
});
FormData を使う場合、通常は Content-Type を自分で指定しません。
// 基本的には書かない
headers: {
"Content-Type": "multipart/form-data"
}
ブラウザが multipart/form-data の boundary を自動で付けるためです。
また、実用時はブラウザから fal.ai などのAPIキーを直接使わないほうが安全です。
構成としては、次のようにします。
ブラウザ
↓
自分のバックエンド / Cloudflare Workers
↓
画像生成API
フロントエンドからは自分の /api/generate-image-to-image に送ります。
APIキーはバックエンドや Workers の環境変数として持たせます。
11. fal.ai などのAPIではアップロードと生成を分けて考える
画像生成APIに参照画像を渡す場合、概念的には次の2段階になります。
1. 参照画像ファイルをアップロードする
2. アップロード後のURLをモデル入力に渡す
fal.ai のJavaScriptクライアントを使う場合は、ファイルアップロード用の仕組みや、キュー実行の仕組みがあります。
概念的には次のような流れです。
const imageUrl = await uploadImage(file);
const result = await runImageToImageModel({
prompt,
image_url: imageUrl,
image_size: size
});
重要なのは、ブラウザ内の blob: URL をそのままモデル入力に渡しても、外部APIからは参照できないことです。
外部APIに渡すには、API側から取得できるURL、またはAPIに直接送信したファイルデータが必要です。
12. 実APIではポーリングで生成完了を待つ
ここまでのUIでは、setTimeout() で3秒後に生成完了にしていました。
実際のAPIでは、生成完了を確認する必要があります。
流れはこうです。
submit
↓
request_id を受け取る
↓
status を定期的に確認する
↓
completed なら result を取得する
↓
生成中カードを完成カードに差し替える
疑似コードです。
async function pollGenerationResult(requestId) {
const maxAttempts = 60;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const status = await fetchGenerationStatus(requestId);
if (status.state === "completed") {
return status.result;
}
if (status.state === "failed") {
throw new Error("画像生成に失敗しました。");
}
await sleep(2000);
}
throw new Error("画像生成がタイムアウトしました。");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
setInterval() よりも、for ループと await sleep() のほうが説明しやすいです。
前のステータス確認が終わってから次の確認に進むためです。
UI側では、次のように置き換えます。
const card = createLoadingCard(request);
gallery.prepend(card);
try {
const result = await submitAndPoll(request);
completeCard({
card,
request,
imageUrl: result.imageUrl
});
} catch (error) {
markCardAsError(card, error);
}
実装時には、必ずタイムアウトや最大試行回数を入れます。
無限にポーリングし続けるUIは避けます。
13. APIキーをどこに置くか
APIを使う場合、APIキーの扱いが重要です。
通常のWebアプリでは、ブラウザにAPIキーを直接置かない構成が基本です。
避けたい構成:
ブラウザ
↓ APIキーつき fetch
画像生成API
望ましい構成:
ブラウザ
↓
自分のバックエンド / Cloudflare Workers
↓ APIキーつき fetch
画像生成API
公開するWebアプリなら、APIキーはサーバー側や Cloudflare Workers 側の環境変数で管理します。
一方、個人用の Chrome 拡張機能として作るなら、ユーザー自身のAPIキーを chrome.storage.local に保存する構成も考えられます。
Chrome拡張機能:
options page:
ユーザーがAPIキーを入力
chrome.storage.local:
APIキーを保存
background service worker:
APIキーを読み出してAPIを呼ぶ
ただし、chrome.storage.local は「完全に安全な金庫」ではありません。
ユーザー本人の端末上に保存するだけです。
公開配布する拡張機能に、開発者自身のAPIキーを埋め込む用途には向きません。
14. 履歴をどこに保存するか
今回のUIでは、履歴も考える必要があります。
保存したい情報は、大きく2種類に分けられます。
軽いデータ:
- プロンプト
- モード
- モデル
- サイズ
- 作成日時
- 生成画像URL
- ステータス
重いデータ:
- 参照画像ファイル
- 生成画像ファイル
- サムネイル画像
最初は、画像ファイルそのものを保存しないほうが簡単です。
まずは軽い履歴だけ保存します。
const historyItem = {
id: crypto.randomUUID(),
prompt: request.prompt,
mode: request.mode,
model: request.model,
size: request.size,
imageUrl: result.imageUrl,
createdAt: new Date().toISOString()
};
Chrome拡張機能なら、軽い履歴は chrome.storage.local に保存できます。
async function saveHistoryItem(item) {
const result = await chrome.storage.local.get("generationHistory");
const history = result.generationHistory ?? [];
const nextHistory = [item, ...history].slice(0, 100);
await chrome.storage.local.set({
generationHistory: nextHistory
});
}
最新100件だけ残すようにしておくと、履歴が無限に増えるのを防げます。
const nextHistory = [item, ...history].slice(0, 100);
通常のWebアプリやPWAなら、chrome.storage.local は使えません。
その場合は、次のように考えます。
| 保存先 | 向いているもの |
|---|---|
| localStorage | 軽い設定、少量の履歴 |
| IndexedDB | 画像Blob、サムネイル、大きめの履歴 |
| Cloudflare R2 / S3 | 採用画像の長期保存 |
| サーバー側DB | 複数端末同期、検索、ユーザー管理 |
今回のチュートリアルでは、まず履歴は画面上だけで扱い、発展としてストレージ保存を考えるのがよいです。
15. 画像本体は保存するべきか
画像生成履歴を全部保存しようとすると、すぐに容量が大きくなります。
そのため、次のように分けると現実的です。
試行錯誤の画像:
一時的なURLだけで確認する
一定件数で消す
採用した画像:
ダウンロードする
または R2 / S3 / Google Drive などに保存する
長期的に検索したい履歴:
プロンプト、モデル、サイズ、採用画像URLだけ保存する
SNS投稿や note のアイキャッチ画像では、すべての失敗画像を長期保存する必要はありません。
採用した画像だけを残せば十分なことも多いです。
16. PWAやChrome拡張への発展
今回のUIは、まず1枚HTMLとして作れます。
ただし、考え方は次の形に発展できます。
通常Webアプリ:
URLで開いて使う
PWA:
スマホのホーム画面に追加して使う
Chrome拡張:
PCブラウザのサイドパネルやポップアップとして使う
スマホで使いたいなら、PWA化が候補になります。
PCで note や Claude の横に置きたいなら、Chrome拡張のサイドパネルが候補になります。
どちらの場合も、基本となるUI部品は同じです。
- プロンプト入力
- モード切替
- モデル選択
- サイズ選択
- 参照画像添付
- 生成中表示
- 履歴表示
違うのは、APIキーや履歴をどこに保存するかです。
Chrome拡張:
chrome.storage.local
通常Webアプリ / PWA:
localStorage / IndexedDB / サーバー側保存
まとめ
SNS投稿や note のアイキャッチ画像のためだけなら、画像生成AIつきの月額サービスを追加契約しなくてもよい場合があります。
Claude のような文章AIで画像生成プロンプトを作り、画像生成だけを fal.ai などのAPIに任せる構成でも十分なことがあります。
今回作ったUIは、そのための小さな入力補助画面です。
技術的には、次のようなWeb UIの基本を組み合わせています。
- fixed header
- submitイベント
- DOM追加
- setTimeoutによる疑似非同期処理
- radioによるモード切替
- selectの動的更新
- file input
- URL.createObjectURL
- FormData
- fetch
- polling
- storage
大きな制作環境を作る前に、まずは自分の作業に足りない部分だけを補う小さな画面を作る。
それが、今回のケーススタディです。
発展メモ
実用化するなら、次の順番で進めるのがよさそうです。
第1段階:
1枚HTMLでUIだけ作る
第2段階:
setTimeoutで疑似的な生成中UIを確認する
第3段階:
自分のAPIエンドポイントにfetchする
第4段階:
ポーリングで生成完了を待つ
第5段階:
プロンプト、モデル、サイズ、画像URLを履歴保存する
第6段階:
Chrome拡張、PWA、Cloudflare Workersなどに展開する
最初から全部作る必要はありません。
まずは、画像生成AIの標準UIを置き換えるのではなく、自分の投稿作業を補助する小さなUIとして考えるのがよいと思います。