オンラインのAI画像編集UIは、最小構成なら「画像を選ぶ、指示を書く、生成する」で作れます。しかし、背景変更、不要物除去、補正、アウトペインティング、修復、スタイル変更を一つの画面で扱い、さらに複数モデルを切り替えられるようにすると、フォームの条件分岐が急速に増えます。
モデルAでは1枚入力、モデルBの合成モードでは複数枚入力、4Kは一部の組み合わせだけ、認証やクレジットが必要な操作もある、という状態です。これらをフロントエンドへ個別にハードコードすると、バックエンドの変更とUIがずれます。
本稿では、利用可能な選択肢をバージョン付きのCapability Contractとして配信し、UI・事前検証・送信・復旧を同じ概念で組み立てる方法を整理します。
前提と解決したい問題
対象は、画像をアップロードし、自然言語で編集内容を指定し、現在利用可能なモデルとオプションで非同期生成するWeb UIです。
避けたいのは次の状態です。
- 選択できるのにサーバーでは実行できない
- モデル変更後も無効な解像度が残る
- 処理中にフォーム変更が実行中ジョブへ混入する
- タイムアウト後の再送でジョブが二重作成される
- モデル成功をそのまま「利用可能な結果」と扱う
Capability Contractの型
能力はモデル単位ではなく「モデルとモードの組み合わせ」にぶら下げます。
type CapabilityDocument = {
version: string;
models: Array<{
id: string;
modes: Array<{
id: "single_edit" | "multi_fusion";
minSources: number;
maxSources: number;
acceptedMimeTypes: string[];
maxBytesPerSource: number;
resolutions: Array<"standard" | "hd" | "4k">;
aspectRatios: string[];
requiresAuth: boolean;
creditCost: number;
}>;
}>;
};
「製品のどこかに4Kがある」ことと、「現在のモードで4Kを使える」ことは別です。入力枚数、解像度、アスペクト比、認証、コストを同じ分岐へ配置すると、表示条件と送信条件を一致させやすくなります。
フォームは能力から導出する
初期表示では能力文書を取得し、有効なモデルとモードだけを選びます。親の選択が変わったときは、子の値をすべてリセットするのではなく、まだ有効な値だけ保持します。
function reconcileSelection(
previous: EditSelection,
capability: ModeCapability,
): EditSelection {
return {
...previous,
resolution: capability.resolutions.includes(previous.resolution)
? previous.resolution
: capability.resolutions[0],
aspectRatio: capability.aspectRatios.includes(previous.aspectRatio)
? previous.aspectRatio
: capability.aspectRatios[0],
};
}
値を置き換えた場合は、理由も表示します。黙ってフォーム全体を初期化すると、ユーザーが書いたプロンプトや選択の意図まで失われるためです。
クライアント検証とサーバー検証を分ける
クライアント側では、入力枚数、宣言されたMIME、ファイルサイズ、ブラウザでのデコード可否、画像の幅と高さを確認します。これは早いフィードバックのためです。
ただし、ファイル名やブラウザ由来のMIMEは信頼できません。サーバー側では実際の内容を識別してデコードし、圧縮サイズだけでなく総ピクセル数とデコード後のメモリ量も制限します。
さらに生成APIは、能力バージョン、モード、入力枚数、解像度、アスペクト比、認証状態を再検証し、コストを再計算します。クライアントが送る見積額は変更検知には使えても、課金根拠にはできません。
送信時に不変スナップショットを作る
送信ボタンを押した時点で、編集可能なフォームから不変のリクエストを作ります。
type EditRequest = Readonly<{
capabilityVersion: string;
sourceAssetIds: string[];
prompt: string;
model: string;
mode: string;
resolution: string;
aspectRatio: string;
quotedCreditCost: number;
}>;
アップロードと生成を分離し、生成側は検証済みのasset IDを参照します。プロンプトを修正して再実行しても、同じ画像バイトを再送する必要がありません。
送信には冪等キーも付けます。ジョブ作成後にHTTP応答だけ失われた場合、同じキーでの再送は同じjob IDを返し、二重実行を防ぎます。
loadingではなく状態機械で表す
idle -> validating_sources -> ready -> submitting
-> processing -> reviewing -> completed
任意の活動状態 -> failed
processingはジョブが受理された状態であり、結果品質を保証しません。reviewingでは元画像、プロンプト、設定スナップショット、生成結果を並べます。文字、顔、境界、反復テクスチャ、光、構図を確認してからcompletedへ進めます。
失敗ごとに復旧操作を変える
- 能力バージョン競合: 最新能力を取得し、画像とプロンプトを保持する
- デコード失敗: 問題のファイルだけを差し替える
- 認証またはクレジット不足: ジョブ投入前に止め、復帰後に再検証する
- プロバイダーのタイムアウト: job IDを保持し、「不明」と「失敗」を区別する
- 結果URLの期限切れ: 既存結果の認可を更新し、再生成しない
- 品質不合格: 元リクエストを保持して新しい試行を作る
復旧可能かどうかだけでなく、失敗した段階と推奨操作をエラー構造に含めると、UIは正しい次の操作を提示できます。
制約を隠さない
対応モデルとオプションは変わり得ます。高解像度や複数画像入力は全モード共通ではなく、一部の操作には認証やクレジットが必要です。対応形式とファイルサイズにも上限があり、生成結果は必ず人が確認する必要があります。
また、プロンプト中心のオンライン編集は、精密なマスク、レイヤー合成、ピクセル単位の修正、バッチ処理をすべて置き換えるものではありません。
この設計を考える具体例として、自作に関わっているAI Photo Editorを使いました。再利用できるポイントは製品名ではなく、能力を契約として扱い、UIとサーバー検証と復旧を同じ境界にそろえることです。