OpenAIExpoMay 7, 2026, 3:30 PM

How to ship an AI mobile app fast with Expo

A condensed section focused on the key takeaways first.

Original Post

Quick Digest

Summary

A condensed section focused on the key takeaways first.

openaienmodel: gpt-5-mini-2025-08-07

How to ship an AI mobile app fast with Expo

Key Points

  • Expo speeds cross-platform shipping
  • Use submit→poll + webhook for long AI jobs
  • Cache videos locally for reliable playback

Summary

CineMe is an AI-driven mobile app that turns a single selfie into a short cinematic video. To hit an aggressive timeline the author prioritized a pragmatic stack: a single Expo React Native codebase, a minimal Flask API, an async job pipeline (immediate jobId + webhook callback), server-side ffmpeg watermarking, and local caching for playback. Expo (EAS) removed build friction so the team could iterate across iOS and Android and push TestFlight builds fast.

Key Points

  • Architecture: client submits base64 image + scene → Flask returns jobId → AI generates video asynchronously → webhook notifies backend → server downloads result, applies ffmpeg watermark, stores output → client polls /status and plays video.
  • Uploads: use expo-file-system to read local image and convert to base64 (avoid file:// URIs and multipart edge cases).
  • Async pattern: submit → receive jobId → store state → poll /status until completed; keep UI decoupled to avoid timeouts.
  • Robust polling: implement cleanup on unmount, timeouts, and retry/backoff (fail after N attempts) to handle flaky mobile networks and avoid memory leaks.
  • Playback reliability: download the remote video to FileSystem.cacheDirectory, cache it, and pass the local file:// URI to expo-video (use a helper like useLocalVideo to wrap this logic).
  • Server-side: accept base64 payloads, return jobId immediately, use webhook callbacks, and run ffmpeg to watermark/process assets.
  • Shipping: use eas build --profile preview to generate installable builds quickly and upload to TestFlight so you can gather user feedback while App Store review runs.
  • Pragmatic tradeoffs: prioritize the tight loop input → transformation → playback over perfect UX or over-engineering to ship fast.

Practical checklist for engineers

  • Implement imageUriToBase64 with expo-file-system before sending to backend.
  • Use a jobId-based API and poll endpoint; store jobId and resume polling across navigations.
  • Add polling cleanup, timeout, and retry counters (fail after ~3 retries).
  • Download and cache completed videos locally; feed local URI to the player.
  • Use X-Device-ID header for lightweight tracking and to authorize downloads.
  • Use EAS to build and push TestFlight builds immediately to parallelize feedback and approval.

Full Translation

Translations

A translation section that keeps the flow of the original article.

openaijamodel: gpt-5-mini-2025-08-07

ExpoでAIモバイルアプリを素早くリリースする方法

ゲスト投稿: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 に公開される完全なプロダクトになりました。制約駆動の実験として始まったものは、ユーザーが実際にダウンロードして使い、共有できるリアルなプロダクトになりました。

最大の教訓:計画ばかりしているよりも素早く出荷すること。実際のユーザー(仮定ではなく)がスマートなイテレーションを導きます。