これはロンドン在住のソフトウェア開発者であり、テクノロジー、ビジネス、人生について学ぶことが好きな好奇心旺盛で思慮深いクリエイター、Kiril Kostevからのゲスト投稿です。CineMeは、1枚のセルフィーをシネマティックなAI生成ビデオに変えるモバイルアプリです。写真をアップロードし、シーン(アクション、アニメ、ホラーなど)を選択すると、数秒以内にそのワールドに変身した自分の短いビデオが得られます。TikTokやReelsに投稿する準備ができています。
制約条件
制約は厳しいものでした。2週間以内にリリースしたいという目標です。これにより、完璧なUXもなく、過度なエンジニアリングもなく、入力→変換→再生という緊密なループだけに絞られました。
AIビデオアプリにExpoが最適な理由
Expoはこのプロジェクトにとって明らかな選択肢でした。単一のReact Nativeコードベースは、プラットフォーム間の相違がないことを意味します。Expoのビルドサービスを使用すれば、ローカル開発からTestFlightまで1つのコマンドで進むことができます。XcodeやGradleと格闘する必要はありません。iOSとAndroid全体で迅速に反復する機能により、摩擦の全カテゴリが削除されました。これは時間が主な制約である場合に必要なものです。
非同期AIビデオパイプラインの仕組み
アプリは、シンプルながら効果的な非同期パイプラインの周りに構築されています。
- Expo(React Nativeアプリ) 軽量なユーザー追跡のための一意のX-Device-IDヘッダーを含むリクエストを送信します。
- Flask API base64エンコードされた画像とシーンプロンプトを受け入れます。これによりバックエンドがシンプルに保たれ、マルチパートアップロードの複雑さが回避されます。
- AIモデル(ビデオ生成) 重い処理を処理します。APIは即座にジョブIDを返し、ビデオを非同期で処理します。
- Webhookコールバック→Flask 生成が完了すると、webhookが通知を受け取ります。Flaskは以下を実行します。
- 結果をダウンロード
- ffmpegを使用してウォーターマークを追加
- 処理されたビデオを保存
- クライアントポーリング(/status) アプリはジョブIDを使用して5秒ごとにポーリングし、ビデオの準備ができるまで待機します。
- 再生 最終ビデオが返され、expo-videoを使用して再生されます。
このアーキテクチャはリクエストのブロッキングを回避し、アプリの応答性を保ちながら、長時間実行されるAIジョブをサポートします。
React NativeでAIビデオアプリを構築する際の4つの難しい問題
1. ファイルアップロード(file://を送信できない)
React NativeのファイルURIはデバイスに対してローカルです。サーバーはそれらにアクセスできません。
解決策:
- expo-file-systemを使用して画像を読み込む
- base64に変換してリクエストボディで送信
これはオーバーヘッドを追加しますが、デバイス全体での互換性を保証します。
import { File } from 'expo-file-system';
async function imageUriToBase64(imageUri: string): Promise<string> {
const file = new File(imageUri);
const base64 = file.base64();
return base64;
}
...
const image_base64 = await imageUriToBase64(imageUri);
const response = await fetch(`${BASE_URL}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': deviceId,
'AnyOtherHeaders': auth_header
},
body: JSON.stringify({
image_base64: image_base64,
scene_id: sceneId,
}),
});
2. 非同期ジョブパターン(送信→ポーリング)
AI生成は即座ではありません。正しいパターンは以下の通りです。
- リクエストを送信→jobIdを受け取る
- jobIdを状態に保存
- 完了するまで/statusをポーリング
- 結果画面に移動
これはUIを処理から分離し、タイムアウトを回避します。
export async function pollJobStatus(jobId: string): Promise<JobStatus> {
console.log('[CineMe API] Polling job status:', jobId);
const deviceId = await getDeviceId();
const response = await fetch(`${BASE_URL}/status/${jobId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': deviceId,
'AnyOtherHeaders': auth_header
},
});
if (!response.ok) {
throw new ApiError(response.status, 'poll_failed', `HTTP ${response.status}`);
}
return response.json() as Promise<JobStatus>;
}
3. 堅牢なポーリングフック
ポーリングはネットワークが失敗するまで単純に聞こえます。本番対応版には以下が含まれます。
- アンマウント時のクリーンアップ(メモリリークを回避)
- タイムアウト処理(X秒後に失敗)
- pollFailureCount >= 3でのリトライロジック
これは無限ループを防ぎ、不安定なモバイルネットワークでの復元力を向上させます。
const startPolling = useCallback((jobId: string) => {
pollFailureCount.current = 0;
pollRef.current = setInterval(async () => {
try {
const status = await pollJobStatus(jobId);
setProgress(status.progress);
if (status.status === 'completed' && status.video_url) {
cleanup();
await refreshCredits();
router.replace({
pathname: '/result',
params: {
videoUrl: status.video_url,
sceneLabel: paramsRef.current.sceneLabel ?? '',
thumbnailUrl: status.thumbnail_url ?? '',
},
});
} else if (status.status === 'failed') {
cleanup();
setFailedMessage({
title: 'Generation failed',
body: status.error ?? "We couldn't generate your video. Please try again.",
cta: 'Try Again',
onPress: () => {
jobIdRef.current = null;
handleRetry();
},
});
setFailed(true);
}
} catch (err: any) {
pollFailureCount.current += 1;
console.warn('[poll] error:', err, `(${pollFailureCount.current} failures)`);
if (pollFailureCount.current >= 3) {
cleanup();
setFailedMessage({
title: 'Connection lost',
body: 'We lost connection to the server. Your video may still be generating. Tap to check again.',
cta: 'Check Again',
onPress: handleRetry,
});
setFailed(true);
}
}
}, POLL_INTERVAL_MS);
}, [cleanup, router]);
4. ビデオ再生の制約
useVideoPlayerは認証されたリモートURLを確実に処理しません。ローカルfile:// URIを期待しています。
解決策:
- ビデオの準備ができた後、ローカルにダウンロード
- デバイスにキャッシュ
- ローカルパスをプレイヤーに渡す
useLocalVideoのようなヘルパーはこのロジックをラップします(ダウンロード→キャッシュ→ローカルURIを返す)。これにより再生の信頼性と起動時間が大幅に向上します。
useLocalVideo.tsx
import * as FileSystem from 'expo-file-system/legacy';
const [localUri, setLocalUri] = useState<string | null>(null);
...
const cacheKey = remoteUrl.split('/').pop();
const localPath = `${FileSystem.cacheDirectory}${cacheKey}`;
const info = await FileSystem.getInfoAsync(localPath);
if (info.exists) {
if (!cancelled) {
setLocalUri(localPath);
}
return;
}
const deviceId = await getDeviceId();
if (!deviceId) throw new Error('No device ID');
const download = await FileSystem.downloadAsync(
remoteUrl,
localPath,
{
headers: {
'X-Device-ID': deviceId,
'AnyOtherHeaders': auth_header
}
}
);
setLocalUri(download.uri);
result.tsx
import { useVideoPlayer, VideoView } from 'expo-video';
const { localUri, loading, error } = useLocalVideo(videoUrl ?? null);
...
const player = useVideoPlayer(localUri ?? '', (p) => {
p.loop = true;
if (localUri) p.play();
});
{}
<View style={styles.videoWrap}>
<VideoView player={player} style={styles.video} contentFit="contain" />
</View>
Expoのサービスを使用してTestFlightとアプリストアにリリース
Expoのクラウドサービスは最大の力の乗数です。
eas build --profile previewを使用すると、インストール可能なビルドを素早く生成できます
- 同じ日にTestFlightにプッシュできます
重要な洞察: App Store承認を待たないでください。代わりに以下を実行します。
- 即座にTestFlightにアップロード
- 早期ユーザー/インフルエンサーとの共有を開始
- Appleレビューが保留中の間にフィードバックを収集
これにより成長と承認が並列化され、数日(または数週間)が節約されます。
ExpoでCineMeをリリースして学んだこと
2週間の期限を逃しました。しかし36日間でCineMeはアイデアから完全にリリースされた製品へと進化しました。App StoreとGoogle Playの両方でライブです。
制約駆動型の実験として始まったものは、今では人々がダウンロード、使用、共有できる実際の製品になっています。
試してみることができます。
最大の教訓: 永遠に計画するより、素早くリリースする方が優れています。ライブになると、仮定ではなく実際のユーザーがより賢い反復を推進します。