ゲスト投稿:CineMe と作者について
この投稿は Kiril Kostev によるゲスト投稿です。Kiril はロンドン在住のソフトウェア開発者で、技術・ビジネス・ライフについて学ぶことが好きな好奇心旺盛で思慮深いクリエイターです。CineMe は単一の自撮り写真をシネマティックなAI生成ビデオに変換するモバイルアプリです。写真をアップロードし、シーン(アクション、アニメ、ホラーなど)を選ぶと、数秒でその世界に変換された短いビデオが生成され、TikTokやReelsに投稿できる形で手に入ります。
制約と優先順位付け
目標は「2週間以内にリリースする」ことでした。これにより容赦ない優先順位付けが求められ、完璧なUXや過剰な設計を避け、入力 → 変換 → 再生という短いループに集中しました。
なぜ Expo がAIビデオアプリに適していたのか
Expo はこのプロジェクトにとって明白な選択肢でした。単一の React Native コードベースによりプラットフォームごとの分岐がありません。Expo の Build サービスを使えば、ローカル開発から TestFlight までワンコマンドで進められます。Xcode や Gradle に苦労する必要がありません。iOS と Android の両方で素早く反復できる能力は、時間が最大の制約である状況で生じる障壁を丸ごと取り除きます。
非同期のAIビデオパイプラインの仕組み
アプリはシンプルだが効果的な非同期パイプラインを中心に構築されています:
- Expo (React Native app)
- 軽量なユーザートラッキングのために、リクエストに一意の
X-Device-ID ヘッダーを添えて送信します。
- Flask API
- base64 エンコードされた画像とシーンのプロンプトを受け取ります。これによりバックエンドが単純になり、multipart アップロードの複雑さを回避します。
- AI model (video generation)
- 重い処理を担当します。API は即座に job ID を返し、動画生成は非同期で処理されます。
- Webhook callback → Flask
- 生成完了後に webhook で通知を受け、Flask は:
- 結果をダウンロード
- ffmpeg を使ってウォーターマークを追加
- 処理済みの動画を保存
- クライアントのポーリング (
/status)
- アプリは job 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. 非同期ジョブパターン(submit → poll)
AI 生成は即時ではありません。正しいパターンは:
- リクエストを送信 → jobId を受け取る
- jobId を state に保持
/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()
})
{/* Video Player */}
<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 の審査が進行中でもフィードバックを収集する
これにより成長と審査を並列化でき、日(あるいは週)単位の時間を節約できます。
CineMe を Expo で出荷して学んだこと
2 週間の目標は達成できませんでしたが、36 日で CineMe はアイデアから実際に App Store と Google Play に公開される完全なプロダクトになりました。制約駆動の実験として始まったものは、ユーザーが実際にダウンロードして使い、共有できるリアルなプロダクトになりました。
最大の教訓:計画ばかりしているよりも素早く出荷すること。実際のユーザー(仮定ではなく)がスマートなイテレーションを導きます。