はじめに
自撮り写真をアップロードすると、顔や背景はそのままで髪型・髪色だけを変えた画像を返す Web アプリを作りました。
画像編集モデルを呼ぶ部分そのものよりも、その前後の設計のほうが考えることが多かったです。
- 「髪だけ変えて、他は変えないで」をモデルにどう伝えるか
- 有料の API 呼び出しと、ユーザーのクレジット消費をどう整合させるか
- ユーザーがアップロードした画像をどこまで信用するか
この記事では、この 3 点について実際のコードを簡略化して紹介します。スタックは Next.js(App Router / Server Actions)+ TypeScript です。
作ったもの
StyleMirror AI という AI ヘアスタイルチェンジャーです。髪型の指定方法は 3 つあります。
- 用意したプリセット(65 種類)から選ぶ
- なりたい髪型の参考写真をアップロードする
- テキストで説明する(例:
short curly bob, copper red)
髪色はこれとは別に選べるようにしています。
1. プロンプトは「変えるもの」と「変えないもの」に分ける
髪型ごとに長いプロンプトを丸ごと書くと、プリセットが増えるにつれて「顔を変えないで」の部分の書き方がプリセットごとにずれていきます。そうなると、どのプリセットを選んだかで顔の保たれ方が変わってしまいます。
そこで、プロンプトを次の 3 パーツに分けて組み立てるようにしました。
- 変えるもの: 髪型(プリセット / 参考写真 / ユーザーの入力)
- 髪色: 別の設定から生成
- 変えないもの: 全リクエストで共通の固定文
// 髪以外はすべてユーザーのもの、という前提を1か所に集約
const KEEP_FOR_HAIR =
"Keep the face, facial features, skin, makeup, expression, head shape, " +
"clothing, background, and lighting unchanged. " +
"Make the hairline and hair texture look natural.";
function buildHairInstruction(presetPrompt: string, colorPrompt: string) {
return `Change only the person's hair to ${presetPrompt}. ${colorPrompt} ${KEEP_FOR_HAIR}`;
}
プリセット側は「髪型の断片」だけを持ちます。
type HairPreset = {
id: string; // URL パラメータにも使うので変更しない
name: string; // 画面表示用
prompt: string; // モデルに渡す断片
};
const presets: HairPreset[] = [
{ id: "pixie-cut", name: "Pixie cut",
prompt: "a cropped pixie cut with soft side-swept bangs" },
{ id: "shaggy-pixie", name: "Shaggy pixie",
prompt: "a choppy, textured shaggy pixie cut" },
// ...
];
こうしておくと、
- 「変えないもの」の文言を直すとき、1 か所の修正で全プリセットに反映される
- プリセットを追加する人は髪型の表現だけに集中できる
- 同じ考え方を別の機能にも使い回せる
というメリットがあります。メガネの試着や服の着せ替えも同じ形にしていて、「変えないもの」のリストだけを機能ごとに入れ替えています(服の場合は face, hair, pose, body shape... を固定する、など)。
髪色の「おまかせ」と「今のまま」を区別する
髪色には「AI におまかせ」と「今の色のまま」の 2 つの特別な値があります。どちらも「色を指定しない」ように見えますが、モデルへの指示はまったく別です。
function hairColorInstruction(color: HairColor): string {
if (color.id === "ai")
return "Choose a natural, flattering hair color that suits the person and the new style.";
if (color.id === "keep")
return "Keep the person's current hair color exactly as it is.";
return `Hair color: ${color.prompt}.`; // 例: "Hair color: honey blonde."
}
参考写真モードで「おまかせ」を選んだ場合は、参考写真の色もそのまま使うように指示を変えています。
const instruction =
color.id === "ai"
? "Give the person the hairstyle shown in the reference photo — same cut, length, texture, and color."
: `Give the person the hairstyle shown in the reference photo — same cut, length, and texture. ${hairColorInstruction(color)}`;
色について何も書かないと、元の色を残すのか変えてよいのかがモデル任せになります。そのため「おまかせ」も「今のまま」も、それぞれ明示的な一文にしています。UI 上の選択肢と、モデルに渡る文の対応を 1 対 1 にしておくのが大事だと感じました。
2. クレジットは「先に消費して、失敗したら返す」
画像生成は 1 回ごとに API の費用がかかるので、ユーザーはクレジットを消費して生成します。ここで悩んだのが処理の順番です。
- 生成後にクレジットを引く → 生成中にクレジットを使い切られると、取りっぱぐれる
- 生成前にクレジットを引く → 失敗したときに必ず返す必要がある
後者を採用し、次の順番にしました。
// 1. 生成レコードを先に作る(これが課金の参照キーになる)
const [generation] = await db.insert(generations)
.values({ userId, prompt: request.label, model })
.returning({ id: generations.id });
const ref = { type: "generation", id: generation.id };
// 2. API を呼ぶ前にクレジットを消費
try {
await spendCredits({ userId, amount: COST, ref });
} catch (e) {
if (e instanceof InsufficientCreditsError) {
// お金は動いていないので、レコードごと消してよい
await db.delete(generations).where(eq(generations.id, generation.id));
return { ok: false, error: "insufficient_credits" };
}
// それ以外は「消費が実は成功していた」可能性があるので消さずに残す
throw e;
}
// 3. 生成。失敗したら返金
try {
const image = await provider.generateImage({ prompt: request.instruction, ... });
// 保存して completed に更新
} catch (e) {
await refundCredits({ userId, ref });
// failed に更新して、ユーザーには「返金済み」と伝える
}
ポイントは次の 3 つです。
-
消費も返金も、生成 ID で冪等にする。 台帳には
spend_{id}とrefund_{id}の形で記録しているので、リトライしても二重に返金されません。 -
原因がわからない失敗のときは、レコードを消さない。 DB への書き込みは成功したのに応答だけ失われる、ということがありえます。
spend_{id}はあるのにrefund_{id}がない pending のレコードを、あとで突き合わせるための目印として残しています。 - 返金自体が失敗したら、ユーザーに「返金しました」と言わない。 ログに突き合わせ用の情報を残して、エラーとして返します。
あわせて、お金が動く前に弾けるものは先に弾くようにしています。テキスト入力のチェック、レート制限、モデルが利用可能かどうかの確認は、すべてクレジット消費より前です。ブロックされたリクエストは台帳に一切痕跡を残しません。
3. アップロード画像は拡張子も Content-Type も信用しない
ユーザーがアップロードした画像は、ファイル名や File.type ではなく、先頭のバイト列(マジックナンバー)で形式を判定しています。
function sniffImageType(bytes: Buffer): string | null {
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
return "image/jpeg";
if (bytes.length >= 8 &&
bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
return "image/png";
if (bytes.length >= 12 &&
bytes.toString("ascii", 0, 4) === "RIFF" &&
bytes.toString("ascii", 8, 12) === "WEBP")
return "image/webp";
return null; // それ以外は受け付けない
}
サイズについては 2 段構えにしています。
- クライアント側: アップロード前に長辺 1600px 以下の JPEG に縮小する(だいたい 0.3〜2MB)
- サーバー側: 1 枚あたり 3.5MB を超えたら拒否する(クライアントを通らないリクエストへの保険)
参考写真モードでは 2 枚送るので、縮小しておかないと Server Actions のボディサイズ上限やプロキシの制限にすぐ当たります。スマホで撮った写真はそのままだと数 MB を超えることが多いので、送信前に縮小しておけばアップロード待ちの時間も短くなります。
まとめ
- プロンプトは「変えるもの」と「変えないもの」に分け、「変えないもの」は機能ごとに共通の定数にする
- UI の選択肢と、モデルに渡る文の対応を 1 対 1 にする(「おまかせ」と「今のまま」は別物)
- 有料 API の前にクレジットを消費し、失敗したら冪等に返金する。原因不明の失敗はレコードを残して後から突き合わせる
- アップロード画像は先頭バイトで形式を判定し、クライアントでの縮小とサーバーでの上限の 2 段構えにする
画像生成 AI を組み込んだサービスを作る方の参考になれば嬉しいです。