0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

画像編集AIで「髪型だけ」を変えるWebアプリを作ったときの実装メモ(プロンプト分解・先払い&返金・入力画像の検証)

0
Posted at

はじめに

自撮り写真をアップロードすると、顔や背景はそのままで髪型・髪色だけを変えた画像を返す Web アプリを作りました。

画像編集モデルを呼ぶ部分そのものよりも、その前後の設計のほうが考えることが多かったです。

  • 「髪だけ変えて、他は変えないで」をモデルにどう伝えるか
  • 有料の API 呼び出しと、ユーザーのクレジット消費をどう整合させるか
  • ユーザーがアップロードした画像をどこまで信用するか

この記事では、この 3 点について実際のコードを簡略化して紹介します。スタックは Next.js(App Router / Server Actions)+ TypeScript です。

作ったもの

StyleMirror AI という AI ヘアスタイルチェンジャーです。髪型の指定方法は 3 つあります。

  1. 用意したプリセット(65 種類)から選ぶ
  2. なりたい髪型の参考写真をアップロードする
  3. テキストで説明する(例: 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 を組み込んだサービスを作る方の参考になれば嬉しいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?