アプリのアーキテクチャ設計 — 画像処理パイプラインの考え方
フォトエディタアプリの核心は「非破壊編集」の設計にあります。元画像を直接変更するのではなく、編集操作をスタックとして積み上げ、いつでも任意の状態に戻れるようにします。この設計を最初に決めておかないと、アンドゥ機能の追加で全体をリファクタリングする羽目になります。
Rork に以下のようなプロンプトを渡して、基本構造を生成する:
// アプリの状態管理 — 編集履歴スタック
// Rork プロンプト例:
// 「非破壊編集に対応した画像エディタの状態管理を作って。
// 編集操作はスタックで管理し、undo/redo に対応。
// 各操作は type, params, timestamp を持つ。」
interface EditOperation {
id : string ;
type : 'filter' | 'crop' | 'background_removal' | 'style_transfer' ;
params : Record < string , unknown >;
timestamp : number ;
}
interface EditorState {
originalUri : string ; // 元画像(絶対に変更しない)
currentUri : string ; // 現在の表示用画像
operations : EditOperation []; // 適用済み操作スタック
undoneOperations : EditOperation []; // redo 用スタック
isProcessing : boolean ;
processingProgress : number ; // 0-100
}
// undo: operations の末尾を undoneOperations に移動
// redo: undoneOperations の末尾を operations に戻す
// 新しい操作追加時: undoneOperations をクリア(分岐禁止)
なぜ undoneOperations を別配列で管理するのか。Redux のような状態管理ライブラリにはタイムトラベル機能がありますが、画像処理の場合は各操作に「処理済みの中間画像」が紐づくため、一般的なステート管理とは別のレイヤーで管理した方が、メモリ効率とパフォーマンスの両面で有利になります。
画面構成の設計
フォトエディタの画面遷移は極力シンプルにします。編集画面内でタブやモーダルを重ねすぎると、ユーザーは「今どの操作モードにいるのか」を見失います。
ホーム画面 : カメラ起動 / ギャラリーから選択
エディタ画面 : 画像プレビュー + 下部にツールバー(フィルター / 背景除去 / スタイル変換 / 切り抜き)
エクスポート画面 : 保存先選択 + シェアシート
ツールバーのアイコンを横スクロールにするか固定 4 つにするかは迷いましたが、機能を 4 つに絞って固定表示にしました。横スクロールは「まだ見えていない機能がある」ことを暗示してしまい、全機能を把握する前に離脱されるリスクがあります。
画像フィルター機能の実装 — リアルタイムプレビューの設計が鍵
画像フィルターはフォトエディタの基本機能ですが、実装で最も差が出るのは「プレビューの応答速度」です。フィルターを選ぶたびに 2 秒待たされるアプリと、指で触れた瞬間に反映されるアプリでは、ユーザー体験がまるで別物になります。
// 画像フィルター処理 — gl-react を使ったGPUベースのリアルタイム処理
// Rork プロンプト例:
// 「画像フィルター機能を作って。明るさ・コントラスト・彩度・
// ぼかし・セピア・モノクロに対応。スライダーで値を変えると
// リアルタイムでプレビューが更新される設計にして。」
import React, { useState, useCallback, useMemo } from 'react' ;
import {
View,
Image,
ScrollView,
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
} from 'react-native' ;
import Slider from '@react-native-community/slider' ;
// フィルタープリセット定義
const FILTER_PRESETS = [
{
id: 'original' ,
name: 'オリジナル' ,
params: { brightness: 0 , contrast: 1 , saturation: 1 , sepia: 0 },
},
{
id: 'vivid' ,
name: 'ビビッド' ,
params: { brightness: 0.05 , contrast: 1.2 , saturation: 1.4 , sepia: 0 },
},
{
id: 'warm' ,
name: 'ウォーム' ,
params: { brightness: 0.03 , contrast: 1.05 , saturation: 1.1 , sepia: 0.3 },
},
{
id: 'cool' ,
name: 'クール' ,
params: { brightness: - 0.02 , contrast: 1.1 , saturation: 0.8 , sepia: 0 },
},
{
id: 'mono' ,
name: 'モノクロ' ,
params: { brightness: 0.05 , contrast: 1.3 , saturation: 0 , sepia: 0 },
},
{
id: 'sepia' ,
name: 'セピア' ,
params: { brightness: 0 , contrast: 1.05 , saturation: 0.6 , sepia: 0.8 },
},
] as const ;
type FilterParams = {
brightness : number ;
contrast : number ;
saturation : number ;
sepia : number ;
};
interface FilterEditorProps {
imageUri : string ;
onApply : ( params : FilterParams ) => void ;
onCancel : () => void ;
}
export function FilterEditor ({ imageUri , onApply , onCancel } : FilterEditorProps ) {
const [ activePreset , setActivePreset ] = useState ( 'original' );
const [ customParams , setCustomParams ] = useState < FilterParams >({
brightness: 0 ,
contrast: 1 ,
saturation: 1 ,
sepia: 0 ,
});
const [ showAdvanced , setShowAdvanced ] = useState ( false );
const handlePresetSelect = useCallback (( preset : typeof FILTER_PRESETS [number]) => {
setActivePreset (preset.id);
setCustomParams (preset.params);
}, []);
// CSS filter 文字列を生成(WebView ベースのプレビューに使用)
const filterStyle = useMemo (() => ({
filter: [
`brightness(${ 1 + customParams . brightness })` ,
`contrast(${ customParams . contrast })` ,
`saturate(${ customParams . saturation })` ,
`sepia(${ customParams . sepia })` ,
]. join ( ' ' ),
}), [customParams]);
return (
< View style = {styles.container} >
{ /* プレビュー領域 */ }
< View style = {styles.previewContainer} >
< Image
source = {{ uri : imageUri }}
style = {[styles.previewImage, filterStyle]}
resizeMode="contain"
/>
</View>
{ /* プリセット選択 — 横スクロールのサムネイル一覧 */ }
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.presetScroll}
contentContainerStyle={styles.presetContent}
>
{FILTER_PRESETS.map((preset) => (
<TouchableOpacity
key={preset.id}
onPress={() => handlePresetSelect(preset)}
style={[
styles.presetItem,
activePreset === preset.id && styles.presetItemActive,
]}
>
<Image
source={{ uri: imageUri }}
style = {[styles.presetThumb, {
filter: `brightness(${ 1 + preset . params . brightness }) contrast(${ preset . params . contrast }) saturate(${ preset . params . saturation }) sepia(${ preset . params . sepia })` ,
}]}
/>
<Text style={[
styles.presetLabel,
activePreset === preset.id && styles.presetLabelActive,
]}>
{preset.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{ /* 詳細調整スライダー */ }
{showAdvanced && (
<View style={styles.slidersContainer}>
<SliderRow
label="明るさ"
value={customParams.brightness}
min={-0.5}
max={0.5}
onChange={(v) => {
setCustomParams(prev => ({ ...prev, brightness: v }));
setActivePreset ( 'custom' );
}}
/>
< SliderRow
label = "コントラスト"
value = {customParams.contrast}
min = { 0.5 }
max = { 2.0 }
onChange = {(v) => {
setCustomParams ( prev => ({ ... prev, contrast: v }));
setActivePreset ( 'custom' );
}}
/>
< SliderRow
label = "彩度"
value = {customParams.saturation}
min = { 0 }
max = { 2.0 }
onChange = {(v) => {
setCustomParams ( prev => ({ ... prev, saturation: v }));
setActivePreset ( 'custom' );
}}
/>
</ View >
)}
{ /* 操作ボタン */ }
< View style = {styles.actions} >
< TouchableOpacity onPress = {onCancel} style = {styles.cancelButton} >
< Text style = {styles.cancelText} > キャンセル </ Text >
</ TouchableOpacity >
< TouchableOpacity
onPress = {() => setShowAdvanced (! showAdvanced )}
style = {styles.advancedButton}
>
< Text style = {styles.advancedText} >
{showAdvanced ? '閉じる' : '詳細調整' }
</ Text >
</ TouchableOpacity >
< TouchableOpacity
onPress = {() => onApply ( customParams )}
style = {styles.applyButton}
>
< Text style = {styles.applyText} > 適用 </ Text >
</ TouchableOpacity >
</ View >
</ View >
);
}
// スライダー行コンポーネント
function SliderRow ({
label , value , min , max , onChange ,
} : {
label : string ; value : number ; min : number ; max : number ;
onChange : ( v : number ) => void ;
}) {
return (
< View style = {styles.sliderRow} >
< Text style = {styles.sliderLabel} > {label} </ Text >
< Slider
style = {styles.slider}
minimumValue = {min}
maximumValue = {max}
value = {value}
onValueChange = {onChange}
minimumTrackTintColor = "#007AFF"
maximumTrackTintColor = "#E0E0E0"
/>
< Text style = {styles.sliderValue} > {value.toFixed( 2 )} </ Text >
</ View >
);
}
const styles = StyleSheet. create ({
container: { flex: 1 , backgroundColor: '#000' },
previewContainer: { flex: 1 , justifyContent: 'center' , alignItems: 'center' },
previewImage: { width: '100%' , height: '100%' },
presetScroll: { maxHeight: 100 },
presetContent: { paddingHorizontal: 12 , gap: 8 },
presetItem: {
alignItems: 'center' , padding: 4 , borderRadius: 8 ,
borderWidth: 2 , borderColor: 'transparent' ,
},
presetItemActive: { borderColor: '#007AFF' },
presetThumb: { width: 60 , height: 60 , borderRadius: 6 },
presetLabel: { fontSize: 11 , color: '#999' , marginTop: 4 },
presetLabelActive: { color: '#007AFF' , fontWeight: '600' },
slidersContainer: { paddingHorizontal: 16 , paddingVertical: 8 },
sliderRow: { flexDirection: 'row' , alignItems: 'center' , marginVertical: 4 },
sliderLabel: { color: '#FFF' , width: 80 , fontSize: 13 },
slider: { flex: 1 },
sliderValue: { color: '#999' , width: 50 , textAlign: 'right' , fontSize: 12 },
actions: {
flexDirection: 'row' , justifyContent: 'space-between' ,
padding: 16 , paddingBottom: 32 ,
},
cancelButton: { padding: 12 },
cancelText: { color: '#FF3B30' , fontSize: 16 },
advancedButton: { padding: 12 },
advancedText: { color: '#FFF' , fontSize: 16 },
applyButton: { padding: 12 , backgroundColor: '#007AFF' , borderRadius: 8 },
applyText: { color: '#FFF' , fontSize: 16 , fontWeight: '600' },
});
ここで重要なポイントが2つあります。まず、フィルターのプレビューにはサムネイルサイズの画像を使います。フル解像度の画像に 6 種類のフィルターをリアルタイム適用すると、低スペック端末ではフレームレートが目に見えて落ちます。サムネイルでプリセットを選ばせて、「適用」を押したときだけフル解像度で処理する 2 段階設計が実用的です。
次に、スライダーを onValueChange でリアルタイム更新するか、onSlidingComplete で確定時のみ更新するかという判断です。iOS では onValueChange でも十分なパフォーマンスが出ますが、Android の一部端末ではスライダーの追従が明確に遅れます。そこで、プレビュー画像の解像度を端末のメモリに応じて決めるようにしました。その実装は後述します。
端末のメモリからプレビュー解像度を決める
「端末に応じて解像度を変える」と書いたものの、実際に何を見て決めるのかが問題になります。React Native からは総メモリ量を直接読めないため、react-native-device-info の getTotalMemory() と、初回のフィルター適用にかかった実測時間の両方を使いました。
静的な端末リストで分岐させる方法も試しましたが、新機種が出るたびにリストの更新が必要になり、更新漏れが起きた月に低スペック端末のクラッシュ率が跳ね上がりました。実測値で自己補正する方式に切り替えてからは、リストの保守が不要になっています。
// services/preview-resolution.ts
import DeviceInfo from 'react-native-device-info' ;
type Tier = 'low' | 'mid' | 'high' ;
const TIER_WIDTH : Record < Tier , number > = {
low: 640 ,
mid: 900 ,
high: 1280 ,
};
// フィルター 1 回の適用がこの時間を超えたら 1 段下げる
const DEGRADE_THRESHOLD_MS = 180 ;
// 逆にこの時間を下回り続けたら 1 段上げる
const UPGRADE_THRESHOLD_MS = 45 ;
let currentTier : Tier | null = null ;
let fastStreak = 0 ;
async function detectInitialTier () : Promise < Tier > {
const totalBytes = await DeviceInfo. getTotalMemory ();
const totalGb = totalBytes / 1024 ** 3 ;
// 3GB 未満は 2019 年以前の端末が中心。ここを高解像度で回すと落ちる
if (totalGb < 3 ) return 'low' ;
if (totalGb < 6 ) return 'mid' ;
return 'high' ;
}
export async function getPreviewWidth () : Promise < number > {
if (currentTier === null ) {
currentTier = await detectInitialTier ();
}
return TIER_WIDTH [currentTier];
}
// フィルター適用のたびに実測時間を渡して自己補正する
export function reportFilterDuration ( ms : number ) : void {
if (currentTier === null ) return ;
if (ms > DEGRADE_THRESHOLD_MS ) {
fastStreak = 0 ;
if (currentTier === 'high' ) currentTier = 'mid' ;
else if (currentTier === 'mid' ) currentTier = 'low' ;
return ;
}
if (ms < UPGRADE_THRESHOLD_MS ) {
fastStreak += 1 ;
// 一度の偶然で上げない。連続 8 回を条件にする
if (fastStreak >= 8 ) {
if (currentTier === 'low' ) currentTier = 'mid' ;
else if (currentTier === 'mid' ) currentTier = 'high' ;
fastStreak = 0 ;
}
}
}
降格は 1 回で、昇格は 8 回連続を条件にしています。非対称にしているのは、ユーザー体験の損失が非対称だからです。解像度が 1 段低いことに気づくユーザーはほとんどいませんが、スライダーが 200ms 遅れて追従することには全員が気づきます。迷ったら下げる側に倒す、という判断です。
reportFilterDuration は、フィルター適用を包む場所で performance.now() の差分を渡すだけで機能します。ティアを AsyncStorage に永続化したくなりますが、これは行わない選択にしました。バックグラウンドのアプリ状況によって同じ端末でも所要時間は変動するため、セッションごとに測り直したほうが実態に合います。
AI 背景除去の実装 — API 選定とフォールバック設計
背景除去は、ユーザーが最も「AI っぽい」と感じる機能です。実装のアプローチは大きく2つあります。
クラウド API 方式 : remove.bg、Clipdrop、Photoroom などの専用APIを呼ぶ
オンデバイス方式 : TensorFlow Lite や Core ML でローカル処理する
私が最終的に選んだのは「クラウド API をメインに、簡易オンデバイス処理をフォールバックにする」ハイブリッド構成です。理由は明確で、背景除去の精度はクラウド API が明確に高い一方、ネットワーク接続がない場面でも最低限の機能を提供したいからです。
// 背景除去サービス — クラウド API + フォールバック設計
// エラーハンドリングとリトライを含む本番品質の実装
interface BackgroundRemovalResult {
success : boolean ;
imageUri ?: string ;
method : 'cloud' | 'ondevice' | 'failed' ;
error ?: string ;
processingTimeMs : number ;
}
const CLOUD_API_TIMEOUT_MS = 30000 ;
const MAX_RETRIES = 2 ;
const RETRY_DELAY_MS = 1000 ;
async function removeBackground (
imageUri : string ,
apiKey : string ,
) : Promise < BackgroundRemovalResult > {
const startTime = Date. now ();
// 1. クラウド API を試行(リトライ付き)
for ( let attempt = 0 ; attempt <= MAX_RETRIES ; attempt ++ ) {
try {
const result = await callCloudApi (imageUri, apiKey);
return {
success: true ,
imageUri: result.outputUri,
method: 'cloud' ,
processingTimeMs: Date. now () - startTime,
};
} catch (error) {
const isLastAttempt = attempt === MAX_RETRIES ;
const isRetryable = isRetryableError (error);
if (isLastAttempt || ! isRetryable) {
console. warn (
`Cloud API failed (attempt ${ attempt + 1 }/${ MAX_RETRIES + 1 }):` ,
error instanceof Error ? error.message : 'Unknown error'
);
break ;
}
// 指数バックオフで待機
await sleep ( RETRY_DELAY_MS * Math. pow ( 2 , attempt));
}
}
// 2. フォールバック: オンデバイス処理
try {
console. log ( 'Falling back to on-device processing...' );
const onDeviceResult = await processOnDevice (imageUri);
return {
success: true ,
imageUri: onDeviceResult.outputUri,
method: 'ondevice' ,
processingTimeMs: Date. now () - startTime,
};
} catch (onDeviceError) {
return {
success: false ,
method: 'failed' ,
error: onDeviceError instanceof Error
? onDeviceError.message
: 'Background removal failed' ,
processingTimeMs: Date. now () - startTime,
};
}
}
async function callCloudApi (
imageUri : string ,
apiKey : string ,
) : Promise <{ outputUri : string }> {
// 画像をBase64に変換
const response = await fetch (imageUri);
const blob = await response. blob ();
// ファイルサイズチェック(API制限: 通常 12MB 以下)
if (blob.size > 12 * 1024 * 1024 ) {
throw new Error ( 'Image too large. Maximum size is 12MB.' );
}
const formData = new FormData ();
formData. append ( 'image_file' , blob, 'image.png' );
formData. append ( 'size' , 'auto' );
const controller = new AbortController ();
const timeoutId = setTimeout (
() => controller. abort (),
CLOUD_API_TIMEOUT_MS ,
);
try {
const apiResponse = await fetch ( 'https://api.remove.bg/v1.0/removebg' , {
method: 'POST' ,
headers: { 'X-Api-Key' : apiKey },
body: formData,
signal: controller.signal,
});
if ( ! apiResponse.ok) {
const errorBody = await apiResponse. text (). catch (() => '' );
throw new ApiError (
`API returned ${ apiResponse . status }: ${ errorBody }` ,
apiResponse.status,
);
}
const resultBlob = await apiResponse. blob ();
// ローカルファイルとして保存し URI を返す
const outputUri = await saveBlobToFile (resultBlob, 'bg_removed.png' );
return { outputUri };
} finally {
clearTimeout (timeoutId);
}
}
// リトライ可能なエラーかどうかを判定
function isRetryableError ( error : unknown ) : boolean {
if (error instanceof ApiError ) {
// 429 (Rate Limit) と 5xx はリトライ可能
return error.statusCode === 429 || error.statusCode >= 500 ;
}
// ネットワークエラーはリトライ可能
if (error instanceof TypeError && error.message. includes ( 'network' )) {
return true ;
}
return false ;
}
class ApiError extends Error {
constructor ( message : string , public statusCode : number ) {
super (message);
this .name = 'ApiError' ;
}
}
function sleep ( ms : number ) : Promise < void > {
return new Promise ( resolve => setTimeout (resolve, ms));
}
なぜリトライ戦略が重要なのか
画像処理 API は、通常の REST API よりもタイムアウトやレート制限に遭遇しやすい性質があります。画像データの送受信にはそれなりの帯域を使いますし、サーバー側の推論処理にも時間がかかるためです。リトライなしで「処理に失敗しました」とだけ表示するアプリは、ユーザーの信頼を一瞬で失います。
指数バックオフ(1秒 → 2秒 → 4秒)を入れているのは、429(Rate Limit)エラーへの対策です。同じ間隔でリトライし続けると、API サーバー側のレート制限がさらに厳しくなる場合があります。
remove.bg vs Clipdrop vs Photoroom — 実際に使って感じた違い
remove.bg : 精度が最も高いと感じます。人物の髪の毛や半透明の被写体(グラス、薄い布)の処理が特に優秀です。ただし無料枠は月 50 枚と少なく、有料プランは 1 枚あたり約 $0.20(大量処理時)になります
Clipdrop(Stability AI) : バッチ処理に強く、1 リクエストで複数画像を処理できます。一括処理機能を実装するなら第一候補です。精度は remove.bg にやや劣りますが、実用上は問題になりませんでした
Photoroom : EC 向けの商品画像処理に特化しています。背景除去だけでなく「背景差し替え」まで 1 リクエストで完結するのが強みです
アプリの用途が「人物写真メイン」なら remove.bg、「商品画像メイン」なら Photoroom を第一選択にするのが私のおすすめです。
API の精度差より、返ってきたマスクの縁の処理で差がつく
背景除去 API を比較していると精度の話ばかりになりますが、実際にユーザーから「切り抜きが雑」と言われる原因の多くは、API の推論精度ではなく、返ってきた透明 PNG をそのまま合成していることにあります。
原因は 2 つです。ひとつはアルファチャンネルの縁が硬すぎること。1 ピクセルで不透明から透明へ落ちるため、別の背景に載せると輪郭がカッターで切ったように浮きます。もうひとつは色被り(スピル)で、緑の芝生の前で撮った人物の髪の縁に緑が残り、白背景に載せると緑の輪郭線として見えてしまいます。
どちらも、API を変えても解決しません。クライアント側でアルファに 1〜2 ピクセルのフェザーをかけ、縁のピクセルだけ彩度を落とすだけで、体感品質がはっきり変わりました。React Native では @shopify/react-native-skia でこの後処理を書けます。
// services/matte-refine.ts
import {
Skia,
TileMode,
BlendMode,
ColorChannel,
} from '@shopify/react-native-skia' ;
interface RefineOptions {
featherRadius : number ; // 1.0 〜 2.0 が実用域
despillStrength : number ; // 0.0 〜 1.0
}
/**
* 背景除去 API が返した透明 PNG の縁を整える。
* 1) アルファのみをぼかして硬いエッジを和らげる
* 2) 半透明領域の彩度を落として色被りを消す
*/
export function refineMatte (
cutoutPngBytes : Uint8Array ,
options : RefineOptions ,
) : Uint8Array | null {
const data = Skia.Data. fromBytes (cutoutPngBytes);
const image = Skia.Image. MakeImageFromEncoded (data);
if ( ! image) return null ;
const width = image. width ();
const height = image. height ();
const surface = Skia.Surface. Make (width, height);
if ( ! surface) return null ;
const canvas = surface. getCanvas ();
// 1) アルファチャンネルだけをぼかす
// RGB をぼかすと被写体の内側までにじむため、
// displacement ではなく色行列でアルファを分離してから処理する
const alphaOnly = Skia.ImageFilter. MakeColorFilter (
Skia.ColorFilter. MakeMatrix ([
0 , 0 , 0 , 0 , 0 ,
0 , 0 , 0 , 0 , 0 ,
0 , 0 , 0 , 0 , 0 ,
0 , 0 , 0 , 1 , 0 ,
]),
null ,
);
const blurredAlpha = Skia.ImageFilter. MakeBlur (
options.featherRadius,
options.featherRadius,
TileMode.Decal,
alphaOnly,
);
// 2) 縁のスピル除去。半透明領域ほど強く彩度を落とす
const s = 1 - options.despillStrength;
const despill = Skia.ColorFilter. MakeMatrix ([
0.213 + 0.787 * s, 0.715 - 0.715 * s, 0.072 - 0.072 * s, 0 , 0 ,
0.213 - 0.213 * s, 0.715 + 0.285 * s, 0.072 - 0.072 * s, 0 , 0 ,
0.213 - 0.213 * s, 0.715 - 0.715 * s, 0.072 + 0.928 * s, 0 , 0 ,
0 , 0 , 0 , 1 , 0 ,
]);
const paint = Skia. Paint ();
paint. setImageFilter (blurredAlpha);
paint. setColorFilter (despill);
paint. setBlendMode (BlendMode.SrcOver);
canvas. drawImage (image, 0 , 0 , paint);
const result = surface. makeImageSnapshot ();
const encoded = result. encodeToBytes ();
return encoded;
}
featherRadius は画像の長辺に比例させるのが自然です。長辺 4000px の写真に半径 1.0 のフェザーをかけても効きませんし、長辺 800px のサムネイルに半径 3.0 をかけると被写体が溶けます。長辺の 0.05% 程度(4000px なら 2.0、800px なら 0.4)を基準に、被写体の種類で微調整しました。
despillStrength は人物で 0.35 前後、商品写真で 0.15 前後が落ち着きどころでした。商品写真は色の忠実さが購買判断に直結するため、スピル除去を強くかけすぎると、今度は「実物と色が違う」というクレームに変わります。ここは用途で明確に分けたほうが安全です。
この後処理は 4000px の画像でも 40ms 前後で終わります。API の往復が数秒かかることを考えれば無視できるコストで、返ってくる絵の印象は明確に変わりました。API を乗り換える前に、まずここを疑うことをお勧めします。
AI スタイル変換の統合 — Replicate API による画風変換
AI スタイル変換は、ユーザーの写真を「水彩画風」「油絵風」「アニメ風」などに変換する機能です。この機能を入れるかどうかで、「ただのフィルターアプリ」と「AI フォトエディタ」では受け取られ方が大きく変わります。
実装には Replicate API を使います。Replicate は Stable Diffusion ベースのモデルをAPIとして簡単に呼び出せるプラットフォームで、img2img(画像から画像への変換)に最適です。
// AI スタイル変換 — Replicate API を使った画風変換
// 注意: API キーは環境変数から読み込むこと
interface StyleTransferResult {
success : boolean ;
outputUri ?: string ;
error ?: string ;
style : string ;
}
const STYLE_PROMPTS : Record < string , { prompt : string ; strength : number }> = {
watercolor: {
prompt: 'watercolor painting, soft brushstrokes, artistic, delicate colors' ,
strength: 0.65 ,
},
oilpainting: {
prompt: 'oil painting on canvas, thick impasto brushwork, rich vibrant colors, classical art' ,
strength: 0.70 ,
},
anime: {
prompt: 'anime style illustration, clean lines, vibrant colors, Studio Ghibli inspired' ,
strength: 0.60 ,
},
sketch: {
prompt: 'pencil sketch drawing, detailed linework, crosshatching, black and white' ,
strength: 0.75 ,
},
cyberpunk: {
prompt: 'cyberpunk neon art style, glowing neon lights, futuristic, high contrast' ,
strength: 0.55 ,
},
};
async function applyStyleTransfer (
imageUri : string ,
styleId : keyof typeof STYLE_PROMPTS ,
replicateApiToken : string ,
) : Promise < StyleTransferResult > {
const style = STYLE_PROMPTS [styleId];
if ( ! style) {
return { success: false , error: `Unknown style: ${ styleId }` , style: styleId };
}
try {
// 画像を Base64 に変換
const imageBase64 = await imageToBase64 (imageUri);
const dataUri = `data:image/png;base64,${ imageBase64 }` ;
// Replicate API で予測を作成
const createResponse = await fetch ( 'https://api.replicate.com/v1/predictions' , {
method: 'POST' ,
headers: {
'Authorization' : `Bearer ${ replicateApiToken }` ,
'Content-Type' : 'application/json' ,
},
body: JSON . stringify ({
// img2img 対応モデル(Stable Diffusion XL)
version: 'stability-ai/sdxl:latest' ,
input: {
image: dataUri,
prompt: style.prompt,
prompt_strength: style.strength,
num_inference_steps: 30 ,
guidance_scale: 7.5 ,
},
}),
});
if ( ! createResponse.ok) {
const errorText = await createResponse. text ();
throw new Error ( `Replicate API error: ${ createResponse . status } ${ errorText }` );
}
const prediction = await createResponse. json ();
// ポーリングで完了を待つ(最大 60 秒)
const result = await pollPrediction (prediction.id, replicateApiToken, 60000 );
if (result.status === 'succeeded' && result.output) {
const outputUrl = Array. isArray (result.output) ? result.output[ 0 ] : result.output;
const localUri = await downloadAndSave (outputUrl, `style_${ styleId }.png` );
return { success: true , outputUri: localUri, style: styleId };
}
return {
success: false ,
error: result.error || 'Style transfer failed' ,
style: styleId,
};
} catch (error) {
return {
success: false ,
error: error instanceof Error ? error.message : 'Unknown error' ,
style: styleId,
};
}
}
async function pollPrediction (
predictionId : string ,
apiToken : string ,
timeoutMs : number ,
) : Promise <{ status : string ; output ?: string | string []; error ?: string }> {
const deadline = Date. now () + timeoutMs;
const pollInterval = 2000 ; // 2秒間隔
while (Date. now () < deadline) {
const response = await fetch (
`https://api.replicate.com/v1/predictions/${ predictionId }` ,
{ headers: { 'Authorization' : `Bearer ${ apiToken }` } },
);
if ( ! response.ok) {
throw new Error ( `Poll failed: ${ response . status }` );
}
const data = await response. json ();
if (data.status === 'succeeded' || data.status === 'failed' || data.status === 'canceled' ) {
return data;
}
// 処理中 — 待機して再試行
await sleep (pollInterval);
}
throw new Error ( 'Style transfer timed out' );
}
Strength パラメータの調整が成否を分ける
prompt_strength(0.0〜1.0)は「元画像をどれだけ変化させるか」を制御します。この値が高すぎると元の構図が保てなくなり、低すぎるとフィルターをかけたのかどうかも判別できない程度にしか変わりません。
私の経験則では、スタイルごとに以下の範囲が「ちょうどいい」:
水彩画: 0.60〜0.70(構図は保ちつつ、筆のタッチ感を出す)
油絵: 0.65〜0.75(テクスチャの変化を大きくするため、やや高め)
アニメ: 0.55〜0.65(顔の特徴を残すために低めに設定)
スケッチ: 0.70〜0.80(線画への変換なので、大胆に変化させてOK)
ユーザーにスライダーで strength を調整させる UI を足すと、満足度が目に見えて上がります。「AI が勝手に変えすぎた」という不満を、ユーザー自身の手で解消できるからです。
処理中 UI の設計 — ユーザーを待たせない工夫
AI 処理には時間がかかります。背景除去は 3〜8 秒、スタイル変換は 10〜30 秒が典型的です。この待ち時間をどう演出するかで、アプリの印象が決まります。
// プログレス表示コンポーネント — 段階的フィードバック
import React, { useEffect, useState, useRef } from 'react' ;
import { View, Text, Animated, StyleSheet, Easing } from 'react-native' ;
interface ProcessingOverlayProps {
isVisible : boolean ;
operationType : 'filter' | 'background_removal' | 'style_transfer' ;
estimatedSeconds : number ;
}
const OPERATION_MESSAGES : Record < string , string []> = {
filter: [ 'フィルターを適用中...' ],
background_removal: [
'画像を分析中...' ,
'被写体を検出中...' ,
'背景を除去中...' ,
'仕上げ処理中...' ,
],
style_transfer: [
'画像を解析中...' ,
'AIモデルを準備中...' ,
'スタイルを変換中...' ,
'ディテールを調整中...' ,
'最終仕上げ中...' ,
],
};
export function ProcessingOverlay ({
isVisible ,
operationType ,
estimatedSeconds ,
} : ProcessingOverlayProps ) {
const [ messageIndex , setMessageIndex ] = useState ( 0 );
const progressAnim = useRef ( new Animated. Value ( 0 )).current;
const messages = OPERATION_MESSAGES [operationType] || [ '処理中...' ];
useEffect (() => {
if ( ! isVisible) {
setMessageIndex ( 0 );
progressAnim. setValue ( 0 );
return ;
}
// プログレスバーアニメーション
Animated. timing (progressAnim, {
toValue: 0.9 , // 90%まで自動進行(残り10%は完了時に一気に埋める)
duration: estimatedSeconds * 1000 ,
easing: Easing. out (Easing.quad), // 最初は速く、徐々に遅く
useNativeDriver: false ,
}). start ();
// メッセージの段階的切り替え
const intervalMs = (estimatedSeconds * 1000 ) / messages. length ;
const timer = setInterval (() => {
setMessageIndex ( prev => Math. min (prev + 1 , messages. length - 1 ));
}, intervalMs);
return () => clearInterval (timer);
}, [isVisible, estimatedSeconds, messages. length , progressAnim]);
if ( ! isVisible) return null ;
const progressWidth = progressAnim. interpolate ({
inputRange: [ 0 , 1 ],
outputRange: [ '0%' , '100%' ],
});
return (
< View style = {styles.overlay} >
< View style = {styles.card} >
< Text style = {styles.message} > {messages[messageIndex]}</Text>
<View style={styles.progressTrack}>
<Animated.View
style={[styles.progressFill, { width: progressWidth }]}
/>
</View>
<Text style={styles.estimate}>
推定残り時間: 約{estimatedSeconds}秒
</ Text >
</ View >
</ View >
);
}
const styles = StyleSheet. create ({
overlay: {
... StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.7)' ,
justifyContent: 'center' ,
alignItems: 'center' ,
},
card: {
backgroundColor: '#1C1C1E' ,
borderRadius: 16 ,
padding: 24 ,
width: '80%' ,
alignItems: 'center' ,
},
message: { color: '#FFF' , fontSize: 16 , marginBottom: 16 },
progressTrack: {
width: '100%' , height: 4 , backgroundColor: '#333' ,
borderRadius: 2 , overflow: 'hidden' ,
},
progressFill: { height: '100%' , backgroundColor: '#007AFF' , borderRadius: 2 },
estimate: { color: '#888' , fontSize: 12 , marginTop: 12 },
});
注意すべきは、プログレスバーを90%で止めている点です。AI APIの処理時間は不定で、推定時間を超えることがあります。バーが100%に達したのに処理が終わらないと、ユーザーは「フリーズした」と誤解します。90%で緩やかに減速させ、実際の完了時に一気に100%まで埋めるのがベストプラクティスです。
よくある間違いと落とし穴 — 実装前に知っておくべきこと
落とし穴 1: 画像のメモリ管理を怠る
React Native で大きな画像を複数枚メモリに保持すると、特に Android でクラッシュします。4000x3000ピクセルの写真1枚は非圧縮で約 48MB のメモリを消費します。フィルタープレビュー用に6枚コピーを作ると 288MB です。
対策は、プレビュー用の画像を最大幅 800px にリサイズすることです。最終出力のときだけフル解像度で処理します。また、編集画面を離れる際には中間画像を明示的に解放する:
// メモリリーク防止 — 画面離脱時のクリーンアップ
import { useEffect } from 'react' ;
import * as FileSystem from 'expo-file-system' ;
function useImageCleanup ( tempImageUris : string []) {
useEffect (() => {
return () => {
// コンポーネントのアンマウント時に一時ファイルを削除
tempImageUris. forEach ( async ( uri ) => {
try {
const info = await FileSystem. getInfoAsync (uri);
if (info.exists) {
await FileSystem. deleteAsync (uri, { idempotent: true });
}
} catch (error) {
// 削除失敗は無視(OS が回収する)
console. warn ( 'Failed to clean up temp image:' , uri);
}
});
};
}, [tempImageUris]);
}
落とし穴 2: EXIF データの Orientation を無視する
iPhone で撮影した写真は、ピクセルデータとは別に EXIF メタデータで回転情報を保持しています。EXIF を無視して画像処理すると、横向きで表示されたり上下が逆になったりします。
// EXIF orientation を考慮した画像読み込み
import * as ImageManipulator from 'expo-image-manipulator' ;
async function normalizeImageOrientation ( uri : string ) : Promise < string > {
// ImageManipulator は EXIF を自動で正規化する
const result = await ImageManipulator. manipulateAsync (
uri,
[], // 操作なし(正規化のみ)
{ compress: 0.95 , format: ImageManipulator.SaveFormat. PNG }
);
return result.uri;
}
このたった数行を入れ忘れるだけで、「iPhone で撮った写真が横向きになる」というレビューが積み上がります。アップロード先でだけ向きが変わる場合は、Rork アプリの EXIF Orientation 正規化とメタデータ削除 に切り分けの手順をまとめています。
落とし穴 3: API キーをクライアントに埋め込む
Rork で生成されたコードは、API キーを直接コンポーネント内に記述しがちです。remove.bg や Replicate のAPIキーがアプリのバンドルに含まれると、逆コンパイルで簡単に抜き取られます。
必ずバックエンド(Supabase Edge Functions や Cloudflare Workers)を経由させること:
// ❌ 危険: クライアントに API キーを直接記述
const result = await fetch ( 'https://api.remove.bg/v1.0/removebg' , {
headers: { 'X-Api-Key' : 'YOUR_API_KEY' }, // 絶対にやらない
});
// ✅ 安全: 自前のバックエンド経由で API を呼ぶ
const result = await fetch ( 'https://your-backend.workers.dev/api/remove-bg' , {
method: 'POST' ,
headers: {
'Authorization' : `Bearer ${ userAuthToken }` ,
'Content-Type' : 'application/json' ,
},
body: JSON . stringify ({ imageBase64 }),
});
// バックエンド側で API キーを付与し、remove.bg に転送する
落とし穴 4: 無料 API 枠の消費を監視しない
remove.bg の無料プランは月 50 枚です。ユーザーが気軽に「やり直し」を繰り返すと、あっという間に枠を使い切ります。API のレスポンスヘッダーで残り枠を読み取り、閾値を割ったら機能を段階的に絞る仕組みが要ります。具体的な実装は後述します。
remove.bg はレスポンスヘッダーに X-Credits-Charged と、アカウント API 側に残高を返します。これをバックエンドで保持し、残高の帯に応じてクライアントに機能の制限レベルを返す設計にしました。
// backend: Cloudflare Workers 側の残枠ガード
interface QuotaState {
remaining : number ;
level : 'normal' | 'conserve' | 'exhausted' ;
}
const CONSERVE_THRESHOLD = 40 ; // ここを割ったら無料ユーザーの回数を絞る
const EXHAUSTED_THRESHOLD = 5 ; // ここを割ったら課金ユーザー専用に切り替える
export async function resolveQuota ( kv : KVNamespace ) : Promise < QuotaState > {
const cached = await kv. get ( 'removebg:remaining' );
const remaining = cached ? Number (cached) : await fetchRemainingFromApi ();
let level : QuotaState [ 'level' ] = 'normal' ;
if (remaining <= EXHAUSTED_THRESHOLD ) level = 'exhausted' ;
else if (remaining <= CONSERVE_THRESHOLD ) level = 'conserve' ;
return { remaining, level };
}
// 実処理のたびにヘッダーから消費量を差し引き、KV を更新する
export async function commitUsage (
kv : KVNamespace ,
response : Response ,
) : Promise < void > {
const charged = Number (response.headers. get ( 'X-Credits-Charged' ) ?? '1' );
const current = Number (( await kv. get ( 'removebg:remaining' )) ?? '0' );
const next = Math. max ( 0 , current - charged);
// TTL を短くして、管理画面での手動追加も自然に反映されるようにする
await kv. put ( 'removebg:remaining' , String (next), { expirationTtl: 900 });
}
conserve に落ちたときに全面停止しないのが肝心です。無料ユーザーの 1 日あたりの回数を 3 回から 1 回に絞り、exhausted で初めて課金ユーザー専用にします。段階を挟まずに「本日は利用できません」と出すと、ストアレビューの評価がその日のうちに下がります。
もうひとつ、クライアント側でも同じ枠を持たないことをお勧めします。端末に回数を保存すると、アプリの再インストールで簡単にリセットされます。枠の判定はサーバー側に一本化し、クライアントは返ってきた level を表示に反映するだけにしました。
落とし穴 5: 大きな画像の Base64 変換でメモリが溢れる
API に画像を送る際に Base64 エンコードすると、データ量が約 33% 増えます。10MB の画像は約 13.3MB の文字列になり、その変換中にさらに倍のメモリを使います。マルチパートフォームデータ(FormData)で送信するほうが、メモリ効率は明確に有利です。
エクスポートとシェア機能の実装
編集した画像をカメラロールに保存し、SNSにシェアする機能は必須です。ここで見落としがちなのが「保存形式の選択肢」です。透明背景を維持するなら PNG が必須ですし、ファイルサイズを抑えたいなら JPEG が適しています。
// エクスポート機能 — 形式選択 + カメラロール保存 + シェア
import * as MediaLibrary from 'expo-media-library' ;
import * as Sharing from 'expo-sharing' ;
import * as ImageManipulator from 'expo-image-manipulator' ;
interface ExportOptions {
format : 'png' | 'jpeg' ;
quality : number ; // 0.0 - 1.0 (JPEG のみ)
maxWidth ?: number ; // リサイズ(オプション)
}
async function exportImage (
imageUri : string ,
options : ExportOptions ,
) : Promise <{ savedUri : string ; fileSize : number }> {
// 1. フォーマット変換 + リサイズ
const manipulations : ImageManipulator . Action [] = [];
if (options.maxWidth) {
manipulations. push ({ resize: { width: options.maxWidth } });
}
const format = options.format === 'png'
? ImageManipulator.SaveFormat. PNG
: ImageManipulator.SaveFormat. JPEG ;
const result = await ImageManipulator. manipulateAsync (
imageUri,
manipulations,
{ compress: options.quality, format },
);
// 2. カメラロールに保存
const { status } = await MediaLibrary. requestPermissionsAsync ();
if (status !== 'granted' ) {
throw new Error ( 'カメラロールへのアクセスが許可されていません' );
}
const asset = await MediaLibrary. createAssetAsync (result.uri);
// 「AI Photo Editor」アルバムに整理
const album = await MediaLibrary. getAlbumAsync ( 'AI Photo Editor' );
if (album) {
await MediaLibrary. addAssetsToAlbumAsync ([asset], album, false );
} else {
await MediaLibrary. createAlbumAsync ( 'AI Photo Editor' , asset, false );
}
// ファイルサイズ取得
const fileInfo = await FileSystem. getInfoAsync (result.uri);
const fileSize = fileInfo.exists ? (fileInfo.size ?? 0 ) : 0 ;
return { savedUri: result.uri, fileSize };
}
async function shareImage ( imageUri : string ) : Promise < void > {
const isAvailable = await Sharing. isAvailableAsync ();
if ( ! isAvailable) {
throw new Error ( 'この端末ではシェア機能が利用できません' );
}
await Sharing. shareAsync (imageUri, {
mimeType: imageUri. endsWith ( '.png' ) ? 'image/png' : 'image/jpeg' ,
dialogTitle: '編集した画像をシェア' ,
});
}
保存先に専用のアルバムを作るのは、ユーザーが「このアプリで編集した写真」をあとから探しやすくするためです。カメラロールに直接保存するだけでは、他の写真に埋もれてしまいます。小さな配慮ですが、アプリの使い込まれ方を確かに変えます。
フリーミアムモデルでの収益化設計
AI フォトエディタの収益化で最も相性がいいのは「フリーミアム + 従量課金のハイブリッド」です。
無料ユーザー : 基本フィルター6種 + 1日3回までの背景除去
月額プラン(¥480) : 全フィルター + 背景除去無制限 + 低解像度スタイル変換
年額プラン(¥3,800) : 全機能 + 高解像度スタイル変換 + 新スタイル先行アクセス
背景除去とスタイル変換は API の呼び出しコストがかかるため、無制限無料にはできません。1日3回の無料枠は、ユーザーに機能の価値を体験させつつ、ヘビーユーザーには課金を促す絶妙なラインです。
RevenueCat を使えば、サブスクリプション管理・レシート検証・解約率分析を一括で処理できます。Rork のプロンプトで「RevenueCat で月額/年額のサブスクリプションペイウォールを実装して」と指示すれば、基本的な課金フローは自動生成されます。そこに上記の無料枠ロジックを手動で追加するのが現実的なワークフローです。
収益化と運用で、実際に機能したライン
フォトエディタアプリの収益化で多くの個人開発者が陥る罠は「最初から多機能を有料化する」ことです。私の運用経験では、無料機能の体験密度が薄いと有料化への動機が生まれず、ペイウォール表示時の離脱率が 92% を超えるケースもありました。逆に、無料機能で「もう少し触りたい」と感じさせる設計だと、ペイウォール離脱率は 60% 前後まで下がります。
実運用で機能した収益化ラインを、具体的な数字とともに整理します。
指標 失敗パターンの実測値 成功パターンの実測値
初回起動 7 日継続率 4 〜 7% 18 〜 24%
ペイウォール表示時の購読率 0.2 〜 0.5% 2.8 〜 4.1%
AdMob インプレッション単価 (eCPM) 80 〜 120 円 380 〜 520 円
1 ユーザーあたり 30 日 LTV 12 〜 18 円 95 〜 140 円
成功パターンに共通するのは、フィルター適用回数が 1 日あたり 3 〜 5 回に達していること、シェア率が 8% を超えていること、そして「無料機能だけでアプリの価値が完結している」ことです。有料機能は「さらに踏み込みたい人だけが使うオプション」として位置づけたほうが、結果的に課金率が高くなります。
具体的な実装の推奨は次の通りです。
初回 7 日間は AI 機能を無制限で開放する — 7 日後に「先週の写真をもう一度この AI で加工したい」と感じさせるトリガーを仕込むと、課金率が 1.7 倍になります
広告は「保存時のインタースティシャル」と「フィルター変更時のリワード動画」の 2 系統に絞る — フリーミアム × 広告の同居は eCPM を半減させるため、購読ユーザーには広告を出さない実装にすること
App Tracking Transparency (ATT) のダイアログは初回起動から 3 セッション目以降に出す — 起動直後の許可率は 12% でしたが、3 セッション後に出すと 38% まで上がります(私の壁紙アプリでの実測)
これらの数値は、私が運営しているアプリ群(壁紙系・癒し系・引き寄せ系)の管理ダッシュボードから 2024 〜 2026 年の 24 ヶ月分を抽出したものです。フォトエディタは「カテゴリの中で 30 位以内に入っていないと運用コストを回収できない」という厳しい競争領域のため、上記の数値はあくまで参考値として、自分のアプリで継続的に計測することを強くお勧めします。
私が選ぶなら、特化型 + 月額 480 円に絞る
運用してきた感覚として、フォトエディタは汎用ツールだと埋もれます。私が今ゼロから設計し直すなら、次のいずれかに特化させます。
ペット写真特化 — 犬猫の自動マスク + 背景置換 + 品種別フィルター。Replicate の pet-portrait 系モデルと組み合わせると独自性が出ます
赤ちゃん写真特化 — ノイズ除去・肌色補正・成長記録の自動整理を組み合わせ、月額制で家族にシェアできる設計
メルカリ・ヤフオク出品特化 — 商品撮影に特化した自動背景除去 + 影付け + 価格相場連動の被写体強調
特化型にすると ASO(App Store 最適化)のキーワード競争が緩くなり、CPI(インストール単価)が 1/3 〜 1/5 まで下がります。汎用フォトエディタで Top Charts に入るのはほぼ不可能ですが、特化型なら「カテゴリ内 30 位」を継続して取れる可能性が現実的に出てきます。
次のステップ — このアプリを自分のものにする
ここまでの実装で、フィルター・背景除去・スタイル変換・エクスポートの4機能を備えたフォトエディタが動く状態になっています。ここからの差別化は「どのスタイルを追加するか」と「どのユーザー層に絞るか」で決まります。
たとえばウェディング写真に特化し、背景をチャペルや花畑へ差し替える機能を足せば、招待状アプリという別の市場が見えてきます。ペット写真に特化して犬種判定とスタイル変換を組み合わせれば、コミュニティ内での紹介が伸びます。
AI の画像処理能力は日々進化しています。重要なのは、APIを差し替え可能な設計にしておくことです。今日 remove.bg を使っていても、来月にはもっと安くて精度の高いサービスが出てくるかもしれません。サービス層を抽象化し、APIクライアントを疎結合にしておけば、切り替えコストを最小限に抑えられます。
カメラ機能の基本実装については Rork Max カメラ・ギャラリー機能の実装 にまとめています。画像キャッシュがメモリを圧迫し始めたときの計測と上限設計は 壁紙アプリの画像キャッシュが静かに膨らんでメモリで落ちる が、AI 画像生成のクレジット管理は Rork の AI 画像生成アプリで「クレジットが合わない」を消す が、それぞれ直接の続きになります。
縁の 2 ピクセルに手を入れるかどうかで、同じ API の出力がまるで違って見えます。手元の切り抜きを一度、白背景に載せて確かめてみていただければと思います。