ClaudeExpoMay 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.

claudeenmodel: claude-haiku-4-5

Shipping CineMe: Building an AI Video App with Expo in 36 Days

Key Points

  • Async job polling pattern for long-running AI tasks
  • Base64 image encoding to bypass React Native file URI limitations
  • Local video caching for reliable playback on authenticated URLs

Summary

CineMe is a mobile app that transforms selfies into cinematic AI-generated videos using React Native and Expo. Built under aggressive time constraints (targeting 2 weeks, shipped in 36 days), the project demonstrates how Expo's tooling enables rapid iteration across iOS and Android without platform-specific friction.

Key Points

  • Why Expo: Single codebase, unified build pipeline (eas build), no Xcode/Gradle wrestling, fast iteration across platforms
  • Architecture: Async job pattern (submit → poll → playback) decouples UI from long-running AI generation, keeping the app responsive
  • File handling: Convert images to base64 using expo-file-system since React Native file:// URIs aren't server-accessible
  • Polling resilience: Implement cleanup on unmount, timeout handling, and retry logic (fail after 3 consecutive failures) to handle flaky mobile networks
  • Video playback: Download and cache videos locally; useVideoPlayer doesn't reliably handle authenticated remote URLs
  • Distribution strategy: Push to TestFlight immediately while awaiting App Store approval to parallelize growth and feedback gathering
  • Core lesson: Ruthless prioritization and shipping beats perfect UX; real user feedback drives smarter iterations

Technical Stack

  • Frontend: React Native + Expo
  • Backend: Flask API with async job processing
  • Video generation: External AI model with webhook callbacks
  • Video processing: ffmpeg for watermarking
  • Playback: expo-video with local caching

Full Translation

Translations

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

claudejamodel: claude-haiku-4-5

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

これはロンドン在住のソフトウェア開発者であり、テクノロジー、ビジネス、人生について学ぶことが好きな好奇心旺盛で思慮深いクリエイター、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();
});

{/* 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レビューが保留中の間にフィードバックを収集

これにより成長と承認が並列化され、数日(または数週間)が節約されます。

ExpoでCineMeをリリースして学んだこと

2週間の期限を逃しました。しかし36日間でCineMeはアイデアから完全にリリースされた製品へと進化しました。App StoreとGoogle Playの両方でライブです。

制約駆動型の実験として始まったものは、今では人々がダウンロード、使用、共有できる実際の製品になっています。

試してみることができます。

最大の教訓: 永遠に計画するより、素早くリリースする方が優れています。ライブになると、仮定ではなく実際のユーザーがより賢い反復を推進します。