This is a guest post from Kiril Kostev - a London based software developer and curious and thoughtful creator, who likes learning about technology, business, and life.
CineMe is a mobile app that turns a single selfie into a cinematic AI-generated video.
You upload a photo, choose a scene (action, anime, horror, etc.), and within seconds you get a short video of yourself transformed into that world - ready to post on TikTok or Reels.
The constraint was aggressive: I wanted to ship in under two weeks. That forced ruthless prioritization - no perfect UX, no over-engineering, just a tight loop from input → transformation → playback.
Why Expo was the right choice for an AI video app
Expo was the obvious choice for this project. A single React Native codebase means no platform divergence. With Expo’s Build service, I could go from local dev to TestFlight in one command. No wrestling with Xcode or Gradle. The ability to iterate quickly across iOS and Android removed entire categories of friction, which is exactly what you need when time is your main constraint.
How the async AI video pipeline works
The app is built around a simple but effective async pipeline:
- Expo (React Native app)
Sends requests with a unique X-Device-ID header for lightweight user tracking.
- Flask API
Accepts a base64-encoded image and a scene prompt. This keeps the backend simple and avoids multipart upload complexity.
- AI model (video generation)
Handles the heavy lifting. The API returns immediately with a job ID and processes the video asynchronously.
- Webhook callback → Flask
Once generation completes, a webhook receives a notification. Flask then:
- Downloads the result
- Adds a watermark using
ffmpeg - Stores the processed video
- Client polling (
/status)
The app polls every 5 seconds using the job ID until the video is ready.
- Playback
The final video is served back and played using expo-video.
This architecture avoids blocking requests and keeps the app responsive, while still supporting long-running AI jobs.
Four hard problems while building an AI video app on React Native
1. File uploads (you can’t send file://)
React Native file URIs are local to the device. Your server can’t access them. The solution:
- Use
expo-file-systemto read the image - Convert to base64 and send in the request body
This adds overhead but guarantees compatibility across devices.
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. Async job pattern (submit → poll)
AI generation isn’t instant. The correct pattern is:
- Submit request → receive
jobId - Store
jobIdin state - Poll
/statusuntil complete - Navigate to result screen
This decouples UI from processing and avoids timeouts.
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. Robust polling hook
Polling sounds simple until networks fail. The production-ready version includes:
- Cleanup on unmount (avoid memory leaks)
- Timeout handling (fail after X seconds)
- Retry logic with
pollFailureCount >= 3
This prevents infinite loops and improves resilience on flaky mobile networks.
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. Video playback constraints
useVideoPlayer doesn’t reliably handle authenticated remote URLs. It expects a local file:// URI.
Solution:
- Download the video locally after it's ready
- Cache it on device
- Pass the local path to the player
A helper like useLocalVideo wraps this logic (download → cache → return local URI). This dramatically improves playback reliability and startup time.
useLocalVideo.tsximport * as FileSystem from 'expo-file-system/legacy';const [localUri, setLocalUri] = useState<string | null>(null);...const cacheKey = remoteUrl.split('/').pop();const localPath = `${FileSystem.cacheDirectory}${cacheKey}`;// Serve from cache if already downloadedconst 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.tsximport { 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}><VideoViewplayer={player}style={styles.video}contentFit="contain"/></View>
Shipping to TestFlight and the app stores with Expo’s services
Expo’s cloud services are the biggest force multiplier.
eas build --profile previewlets you generate installable builds fast- You can push to TestFlight the same day
The key insight: don’t wait for App Store approval to start distribution.
Instead:
- Upload to TestFlight immediately
- Start sharing with early users/influencers
- Gather feedback while Apple review is pending
This parallelizes growth and approval, saving days (or weeks).
What I learned shipping CineMe with Expo
I missed my two week timeline. But in 36 days CineMe went from idea to a fully shipped product - live on both the App Store and Google Play. What started as a constraint-driven experiment is now a real product people can download, use, and share.
You can try it here:
iOS: https://apps.apple.com/gb/app/cineme/id6760482145
Android: https://play.google.com/store/apps/details?id=com.aiconversations.cineme&hl=en
Or visit the website:
The biggest lesson: shipping fast beats planning forever. Once it’s live, real users - not assumptions - drive smarter iterations.


