アプリのアーキテクチャ設計 — 画像処理パイプラインの考え方
フォトエディタアプリの核心は「非破壊編集」の設計にあります。元画像を直接変更するのではなく、編集操作をスタックとして積み上げ、いつでも任意の状態に戻れるようにします。この設計を最初に決めておかないと、アンドゥ機能の追加で全体をリファクタリングする羽目になります。
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 の一部端末ではスライダーの追従が遅れます。そのため、プレビュー画像の解像度を端末のメモリに応じて動的に変更するロジックを入れるとよい。
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 を第一選択にするのが私のおすすめです。
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で撮った写真が横向きになる」というレビューが大量につく。
落とし穴 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 のレスポンスヘッダーで残り枠を確認し、残りが少なくなったらユーザーに通知する仕組みを入れるべきです。
落とし穴 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 で月額/年額のサブスクリプションペイウォールを実装して」と指示すれば、基本的な課金フローは自動生成されます。そこに上記の無料枠ロジックを手動で追加するのが現実的なワークフローです。
個人開発で 13 年積み上げた、収益化と運用の現実的な数字
フォトエディタアプリの収益化で多くの個人開発者が陥る罠は「最初から多機能を有料化する」ことです。私の運用経験では、無料機能の体験密度が薄いと有料化への動機が生まれず、ペイウォール表示時の離脱率が 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 位以内に入っていないと運用コストを回収できない」という厳しい競争領域のため、上記の数値はあくまで参考値として、自分のアプリで継続的に計測することを強くお勧めします。
私が選ぶなら、AI フォトエディタは「特化型 + 月額 480 円」に絞る
13 年運用してきた感覚として、フォトエディタは汎用ツールだと埋もれます。私が今ゼロから設計し直すなら、次のいずれかに特化させます。
ペット写真特化 — 犬猫の自動マスク + 背景置換 + 品種別フィルター。Replicate の pet-portrait モデルと組み合わせれば独自性が出ます
赤ちゃん写真特化 — ノイズ除去・肌色補正・成長記録の自動整理を組み合わせ、月額制で家族にシェアできる設計
メルカリ・ヤフオク出品特化 — 商品撮影に特化した自動背景除去 + 影付け + 価格相場連動の被写体強調
特化型にすると ASO(App Store 最適化)のキーワード競争が緩くなり、CPI(インストール単価)が 1/3 〜 1/5 まで下がります。汎用フォトエディタで Top Charts に入るのはほぼ不可能ですが、特化型なら「カテゴリ内 30 位」を継続して取れる可能性が現実的に出てきます。
次のステップ — このアプリを自分のものにする
ここまでの実装で、フィルター・背景除去・スタイル変換・エクスポートの4機能を備えたフォトエディタが動く状態になっています。ここからの差別化は「どのスタイルを追加するか」と「どんなユーザー層に絞るか」で決まる。
たとえば、ウェディング写真に特化して背景をチャペルや花畑に差し替える機能を追加すれば、結婚式の招待状アプリとして独立した市場が見える。ペット写真に特化して犬種判定 + スタイル変換を組み合わせれば、ペット好きコミュニティで口コミが広がる。
AI の画像処理能力は日々進化しています。重要なのは、APIを差し替え可能な設計にしておくことです。今日 remove.bg を使っていても、来月にはもっと安くて精度の高いサービスが出てくるかもしれません。サービス層を抽象化し、APIクライアントを疎結合にしておけば、切り替えコストを最小限に抑えられます。
カメラ機能の基本実装については Rork Max カメラ・ギャラリー機能ガイド で解説しています。画像の最適化とキャッシュ戦略は Rork アプリの画像最適化とキャッシュ戦略 が参考になるだろう。AI画像生成の基本概念については Rork Max × AI 画像生成ガイド も合わせて読んでほしい。