47MB の旅行動画を選び、プログレスバーが 100% まで伸びるのを見届けてから Supabase のダッシュボードを開いたところ、そこにあったのは 4.7MB のファイルでした。10 個に分けて送ったはずのチャンクのうち、最後の 1 つだけが残っていたのです。
エラーは一度も出ていません。原因は私の書いたアップロード処理そのものでした。同じパスへ upsert を付けて 10 回書き込めば、当然 10 回目だけが残ります。分割したつもりで、上書きを繰り返しておりました。
以下は、この失敗を含めて、Rork Max を入口に動画アップロード・字幕の自動生成・ハイライト検出・Stripe のクレジット課金までを動かすまでの実装記録です。動くコードと、途中で踏み抜いた場所を一緒に書き残します。
なぜ今AI動画編集アプリなのか — 市場の現実と個人開発者の勝ち筋
動画編集市場を見ると、CapCutやVLLOが大きなシェアを握る一方で、「特定の用途に特化したAI編集ツール」には明確な隙間があります。たとえば「会議録画から自動でハイライトを切り出す」「旅行動画に多言語字幕を一括生成する」「料理動画を工程ごとに分割する」といったニッチな機能は、大手アプリでは実装が遅く、個人開発者が先行できる領域です。
Rork Maxを使うと、ネイティブiOSアプリとしてのUIを素早く生成しながら、バックエンドをCloudflare WorkersやSupabaseで組むことができます。動画処理の重い部分はサーバー側に委ね、アプリ側はアップロードと結果表示に集中します。この責任分担が、個人開発で動画アプリを作る際の現実的な正解です。
前提として、Rork Max アプリ側で FFmpeg を動かそうとするのは避けています。React Native でもネイティブモジュール経由の動画処理は可能ですが、バイナリサイズが大きく増え、長い動画では端末の発熱で処理が落ちます。重い処理はサーバーへ寄せ、アプリ側はアップロードと結果表示に集中させる分担が、個人開発では現実的だと感じています。
アーキテクチャの設計 — 処理の流れとスタックの選定理由
今回実装するシステムの全体像はこうなります。
- ユーザーが動画を選ぶと、アプリが Supabase Storage へ TUS で再開可能アップロード
- 完了後、アプリが Supabase Edge Function へ処理開始をリクエスト
- Edge Function が Whisper API で文字起こし、Gemini でハイライトシーンの検出
- FFmpeg でクリップをトリミングし、字幕をオーバーレイ
- 完成した動画を Supabase Storage から配信
- アプリが Supabase Realtime で完了通知を受け取り、結果を表示
なぜ Supabase を選んだのか。Storage・Database・Edge Functions・Realtime が一体で使えるため、処理の完了通知をリアルタイムでアプリへ返すところに手間がかからないのです。Cloudflare R2 + Workers AI も候補に挙げましたが、再開可能アップロードがサーバー側の機能として最初から用意されている点で、現時点では Supabase を選んでおります。
技術スタックの全体像
使用するライブラリとサービスは次のとおりです。
- フロントエンド(Rork Max生成): expo-image-picker, expo-video, expo-file-system, @supabase/supabase-js
- ストレージ: Supabase Storage(動画ファイル・処理済みクリップ)
- データ管理: Supabase Database(メタデータ・字幕データ・処理ジョブ)
- AIサービス: OpenAI Whisper API(文字起こし)、Google Gemini 2.5 Flash(シーン分析)
- 動画処理: Supabase Edge Function(Deno)+ FFmpeg WebAssembly
- 課金: Stripe(クレジット消費型)
- リアルタイム通知: Supabase Realtime(postgres_changes)
動画アップロードの実装 — 「分割したつもり」から TUS へ
冒頭の 4.7MB は、次のようなコードが生んだものでした。ファイルを 5MB ずつ読み、同じパスへ upsert: true で upload() を繰り返す——分割アップロードのつもりで書いた処理です。
Storage の upload() はオブジェクト単位の書き込みで、追記の API ではありません。同じキーへ 10 回書けば、残るのは最後の 1 回だけです。しかも upsert を付けているので、エラーもログも出ません。いま思えば、成功したように見えることがいちばん厄介でした。
もうひとつ、readAsStringAsync で Base64 にしてから Buffer に戻す往復も筋が悪い実装でした。React Native には素の Buffer がありません。加えて Base64 化はデータをおよそ 1.33 倍に膨らませますから、数百 MB の動画をメモリに載せることになります。
Supabase Storage は TUS プロトコルの再開可能アップロードに対応しています。分割・再送・再開はプロトコル側の仕事で、こちらが書くのは設定だけです。
重いものはサーバーへ、壊れやすいものはプロトコルへ。 自前の分割処理を消した日から、アップロードに起因する問い合わせが届かなくなりました。
// services/videoUpload.ts
import * as tus from 'tus-js-client';
import { supabase } from './supabaseClient';
const PROJECT_ID = 'YOUR_PROJECT_ID';
// 大きなファイルでは直接ストレージホスト名(*.storage.supabase.co)を使う
const RESUMABLE_ENDPOINT =
`https://${PROJECT_ID}.storage.supabase.co/storage/v1/upload/resumable`;
export interface PickedVideo {
uri: string; // expo-image-picker が返す file:// URI
name: string;
type: string; // 'video/mp4' など
}
export async function uploadVideoResumable(
file: PickedVideo,
bucketName: string,
objectName: string,
onProgress: (percent: number) => void
): Promise<string> {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
throw new Error('サインインが必要です');
}
return new Promise<string>((resolve, reject) => {
const upload = new tus.Upload(file as unknown as tus.UploadInput, {
endpoint: RESUMABLE_ENDPOINT,
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
authorization: `Bearer ${session.access_token}`,
// 同じパスを上書きしてよい場合のみ
// 'x-upsert': 'true',
},
uploadDataDuringCreation: true,
removeFingerprintOnSuccess: true,
metadata: {
bucketName,
objectName,
contentType: file.type,
cacheControl: '3600',
},
// 6MB 固定。サーバー側の実装がこの値を前提にしているため変更しない
chunkSize: 6 * 1024 * 1024,
onError: (error) => {
reject(error instanceof Error ? error : new Error(String(error)));
},
onProgress: (uploaded, total) => {
onProgress(Math.round((uploaded / total) * 100));
},
onSuccess: () => resolve(objectName),
});
// 前回の中断が残っていれば、そこから再開する
upload.findPreviousUploads().then((previous) => {
if (previous.length > 0) {
upload.resumeFromPreviousUpload(previous[0]);
}
upload.start();
});
});
}設定のなかで、実際に運用してから意味が分かったものを 4 つ挙げます。
| 項目 | 挙動 | 実装上の意味 |
|---|---|---|
| chunkSize | 6MB 固定(変更しない) | 回線に合わせて小さくしたくなりますが、サーバー側がこの値を前提にしています。速度の調整は端末側ではなくホスト名の選択で行います |
| アップロード URL の寿命 | 最大 24 時間 | 24 時間を超えた再開はできません。翌日まで放置された中断はやり直しになるため、UI 側で「再開できます」と出し続けない設計にしておきます |
| 同一 URL への同時アップロード | 1 クライアントのみ成功し、他は 409 Conflict | 同じジョブを 2 画面から叩かせない。409 は異常ではなく設計どおりの応答なので、リトライ対象から外します |
| 同一パスへ別 URL から | 先に完了した側が成功。x-upsert 指定時は後から完了した側が勝つ |
オブジェクト名にジョブ ID を含めておけば、この分岐を考える必要がなくなります |
そして React Native では、ひとつだけ使えない機能があります。tus-js-client の再開はアップロード URL を Web Storage API に保存する仕組みで動いていますが、React Native には Web Storage API がありません。つまり findPreviousUploads() は常に空の配列を返し、アプリを再起動したあとの再開は既定では効きません。セッションをまたいで再開させたい場合は、AsyncStorage などを背後に持つ urlStorage を自分で渡す必要があります。同一セッション中の通信断からの再送は、上記のままでも効きます。
ピッカーから受け取る URI の扱い
expo-image-picker の launchImageLibraryAsync は、選ばれた動画をアプリのキャッシュへコピーした file:// URI を返します。tus-js-client は React Native 向けに { uri } を持つオブジェクトを受け取り、URI の解決を内部で行いますので、ピッカーの結果をそのまま渡せます。
ph:// で始まる URI に出会うのは、expo-media-library から資産を列挙した場合です。こちらは getAssetInfoAsync() を通して localUri を取り出してから渡してください。同じ「動画を選ぶ」でも、どのモジュールを経由したかで渡せる URI が変わります。
なお mediaTypes に ImagePicker.MediaTypeOptions.Videos を渡す書き方は非推奨になりました。いまは ['videos'] のように文字列の配列を渡します。
これを Rork Max で生成した UI コンポーネントに組み込みます。
// components/VideoUploader.tsx
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { uploadVideoResumable } from '../services/videoUpload';
import { triggerVideoProcessing } from '../services/videoProcessing';
import { planHighlightRoute } from '../utils/videoValidation';
interface VideoUploaderProps {
onJobStarted: (jobId: string) => void;
}
export function VideoUploader({ onJobStarted }: VideoUploaderProps) {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [statusMessage, setStatusMessage] = useState('');
const [error, setError] = useState<string | null>(null);
const pickAndUpload = async () => {
setError(null);
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
setError('動画へのアクセス許可が必要です。設定から許可してください。');
return;
}
const pickerResult = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['videos'],
quality: 1,
videoMaxDuration: 300, // 最大5分
});
if (pickerResult.canceled || !pickerResult.assets?.[0]) return;
const asset = pickerResult.assets[0];
if (!asset.uri || !asset.fileName) {
setError('動画ファイルの読み込みに失敗しました。別の動画をお試しください。');
return;
}
// 100MB の境界でハイライト検出の経路が変わる(後述の planHighlightRoute)
const { error: validationError, route } = planHighlightRoute(asset);
if (validationError || !route) {
setError(validationError ?? 'この動画は処理できません。');
return;
}
setUploading(true);
setStatusMessage('動画をアップロード中...');
const objectName = `videos/${Date.now()}_${asset.fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
let path: string;
try {
path = await uploadVideoResumable(
{ uri: asset.uri, name: asset.fileName, type: asset.mimeType ?? 'video/mp4' },
'video-uploads',
objectName,
(p) => {
setProgress(p);
setStatusMessage(`アップロード中... ${p}%`);
}
);
} catch (err) {
const message = err instanceof Error ? err.message : '不明なエラー';
setError(`アップロードに失敗しました: ${message}`);
setUploading(false);
return;
}
setStatusMessage('AI処理を開始中...');
const { jobId, error: processingError } = await triggerVideoProcessing(path, {
// Gemini へ URL を渡すか Files API へ上げるかを Edge Function に伝える
highlightRoute: route,
});
if (processingError) {
setError('AI処理の開始に失敗しました。時間をおいて再試行してください。');
setUploading(false);
return;
}
onJobStarted(jobId);
setStatusMessage(`AI処理中... 完了時に通知します`);
setUploading(false);
setProgress(0);
};
return (
<View style={styles.container}>
<TouchableOpacity
onPress={pickAndUpload}
disabled={uploading}
style={[styles.button, uploading && styles.buttonDisabled]}
accessibilityRole="button"
accessibilityLabel="動画を選んでAI編集を開始"
>
{uploading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>動画を選んでAI編集を開始</Text>
)}
</TouchableOpacity>
{statusMessage ? (
<Text style={styles.statusText}>{statusMessage}</Text>
) : null}
{progress > 0 && uploading ? (
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${progress}%` }]} />
</View>
) : null}
{error ? (
<Text style={styles.errorText}>{error}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 20 },
button: {
backgroundColor: '#1a1a2e',
padding: 16,
borderRadius: 12,
alignItems: 'center',
},
buttonDisabled: { backgroundColor: '#999' },
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
statusText: { marginTop: 12, color: '#555', textAlign: 'center' },
progressBar: {
marginTop: 8,
height: 6,
backgroundColor: '#eee',
borderRadius: 3,
overflow: 'hidden',
},
progressFill: { height: '100%', backgroundColor: '#3498db' },
errorText: { marginTop: 12, color: '#e74c3c', textAlign: 'center' },
});進捗の表示についてひと言。tus-js-client の onProgress は送信済みバイト数で呼ばれますから、チャンク境界ごとに 6MB 単位で跳ねます。細かいバーを見せたい気持ちは分かるのですが、ここを滑らかに見せる細工をするより、残り時間を出さずに「送信中」と「再送中」を出し分けるほうが、問い合わせは減りました。
Supabase Edge FunctionによるAI処理パイプライン
アップロード完了後の処理の中核となるEdge Functionを実装します。WhisperとGeminiを順次呼び出し、結果をDBに保存します。
// supabase/functions/process-video/index.ts
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const OPENAI_API_KEY = Deno.env.get('OPENAI_API_KEY') ?? '';
const GEMINI_API_KEY = Deno.env.get('GEMINI_API_KEY') ?? '';
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
interface Highlight {
start: number;
end: number;
reason: string;
}
interface ProcessingJob {
jobId: string;
storagePath: string;
userId: string;
options: {
generateSubtitles: boolean;
detectHighlights: boolean;
language: string;
};
}
serve(async (req: Request) => {
if (req.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
let job: ProcessingJob;
try {
job = await req.json() as ProcessingJob;
} catch {
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
await supabase.from('processing_jobs').update({
status: 'processing',
started_at: new Date().toISOString(),
}).eq('id', job.jobId);
try {
const { data: signedUrlData, error: urlError } = await supabase.storage
.from('video-uploads')
.createSignedUrl(job.storagePath, 7200); // 2時間有効(長い動画の処理に対応)
if (urlError || !signedUrlData) {
throw new Error(`ストレージURLの取得に失敗: ${urlError?.message}`);
}
// ①: Whisper APIによる字幕生成(音声のみ取り出してサイズ削減)
let transcriptionVtt: string | null = null;
if (job.options.generateSubtitles) {
// 動画から音声を取り出す処理は別Workerに委任するか
// 小さい動画の場合は直接Whisperに送る
const videoResponse = await fetch(signedUrlData.signedUrl);
if (!videoResponse.ok) {
throw new Error(`動画ファイルの取得に失敗: ${videoResponse.status}`);
}
const videoBlob = await videoResponse.blob();
// Whisper APIのファイルサイズ上限は25MB
if (videoBlob.size > 25 * 1024 * 1024) {
console.warn(`ファイルサイズが上限超過: ${videoBlob.size} bytes — 字幕生成をスキップ`);
} else {
const formData = new FormData();
formData.append('file', videoBlob, 'audio.mp4');
formData.append('model', 'whisper-1');
formData.append('language', job.options.language || 'ja');
formData.append('response_format', 'vtt'); // タイムスタンプ付きVTT形式
const whisperResponse = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${OPENAI_API_KEY}` },
body: formData,
});
if (!whisperResponse.ok) {
const errText = await whisperResponse.text();
throw new Error(`Whisper API エラー (${whisperResponse.status}): ${errText}`);
}
transcriptionVtt = await whisperResponse.text();
}
}
// ②: Gemini Flashによるハイライトシーン検出
let highlights: Highlight[] = [];
if (job.options.detectHighlights) {
const geminiBody = {
contents: [{
parts: [
{
file_data: {
mime_type: 'video/mp4',
file_uri: signedUrlData.signedUrl,
},
},
{
text: `この動画を分析し、最も重要・印象的なハイライトシーンを最大5つ選んでください。
選定基準: 感情的なピーク、重要な発言、アクションシーン、笑い・驚きの瞬間
動画全体の20〜40%がハイライトに含まれるよう調整してください。
以下のJSON形式のみで回答してください(他のテキストは不要):
{"highlights":[{"start_sec":数値,"end_sec":数値,"reason":"日本語で理由"}]}`,
},
],
}],
generationConfig: {
responseMimeType: 'application/json',
maxOutputTokens: 1024,
temperature: 0.3,
},
};
const geminiResponse = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(geminiBody),
}
);
if (geminiResponse.ok) {
const geminiData = await geminiResponse.json();
const jsonText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}';
try {
const parsed = JSON.parse(jsonText);
highlights = (parsed.highlights ?? []).map((h: { start_sec: number; end_sec: number; reason: string }) => ({
start: h.start_sec,
end: h.end_sec,
reason: h.reason,
}));
} catch {
console.warn('Geminiレスポンスのパースに失敗 — ハイライトなしで続行');
}
} else {
console.warn(`Gemini APIエラー: ${geminiResponse.status} — ハイライトなしで続行`);
}
}
// 結果をDBに保存(Realtimeが変更を検知してアプリに通知)
await supabase.from('processing_jobs').update({
status: 'completed',
completed_at: new Date().toISOString(),
result: {
transcription_vtt: transcriptionVtt,
highlights,
video_url: signedUrlData.signedUrl,
},
}).eq('id', job.jobId);
return new Response(JSON.stringify({
success: true,
jobId: job.jobId,
hasTranscription: !!transcriptionVtt,
highlightCount: highlights.length,
}), { headers: { 'Content-Type': 'application/json' } });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '不明なエラー';
console.error('Edge Function エラー:', errorMessage);
await supabase.from('processing_jobs').update({
status: 'failed',
error_message: errorMessage,
completed_at: new Date().toISOString(),
}).eq('id', job.jobId);
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});Gemini へ動画を渡すときの file_data.file_uri は、Files API が発行した URI のほか、公開 URL や署名付き URL も受け取ります。API 側がリクエストの処理中にその URL を取得する仕組みです。ただしこの経路には無視できない上限があり、次の節でまとめます。
掲載のコードは gemini-2.5-flash を呼んでいます。世代を上げるときに確かめる場所は 2 つで、署名付き URL 入力への対応状況(Gemini 2.0 系はこの入力に対応していません)と、JSON で受け取るための指定の書き方です。モデル名だけ差し替えて動かなくなるのは、たいていこの 2 つのどちらかでした。
Supabase Realtimeで処理完了を受け取る
処理完了をアプリがリアルタイムで知るためのフックを実装します。Supabase RealtimeはPostgreSQLのCDCを使ってDBの変更をWebSocketで配信します。
// hooks/useProcessingStatus.ts
import { useEffect, useState, useCallback } from 'react';
import { supabase } from '../services/supabaseClient';
export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed';
interface Highlight {
start: number;
end: number;
reason: string;
}
interface ProcessingResult {
transcription_vtt: string | null;
highlights: Highlight[];
video_url: string;
}
export function useProcessingStatus(jobId: string | null) {
const [status, setStatus] = useState<JobStatus>('pending');
const [result, setResult] = useState<ProcessingResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fetchCurrentStatus = useCallback(async () => {
if (!jobId) return;
const { data } = await supabase
.from('processing_jobs')
.select('status, result, error_message')
.eq('id', jobId)
.single();
if (data) {
setStatus(data.status as JobStatus);
setResult(data.result as ProcessingResult | null);
setErrorMessage(data.error_message as string | null);
}
}, [jobId]);
useEffect(() => {
if (!jobId) return;
// 初回状態取得
fetchCurrentStatus();
// Realtimeサブスクリプション
const channel = supabase
.channel(`processing-job-${jobId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'processing_jobs',
filter: `id=eq.${jobId}`,
},
(payload) => {
const updated = payload.new as {
status: JobStatus;
result: ProcessingResult | null;
error_message: string | null;
};
setStatus(updated.status);
setResult(updated.result);
setErrorMessage(updated.error_message);
}
)
.subscribe((subStatus) => {
if (subStatus === 'SUBSCRIPTION_ERROR') {
// Realtimeが切れた場合はポーリングにフォールバック
console.warn('Realtimeサブスクリプションに失敗 — ポーリングに切り替え');
}
});
// フォールバック: 30秒おきにポーリング(Realtime切断対策)
const pollInterval = setInterval(fetchCurrentStatus, 30000);
return () => {
supabase.removeChannel(channel);
clearInterval(pollInterval);
};
}, [jobId, fetchCurrentStatus]);
return { status, result, errorMessage };
}Stripeクレジット課金モデルの設計と実装
AI機能ごとにクレジットを消費するモデルを設計します。ユーザーは先にStripeでクレジットパックを購入し、機能を使うたびに消費する仕組みです。
// constants/creditCosts.ts
export const CREDIT_COSTS = {
SUBTITLE_GENERATION_PER_MIN: 2, // 字幕生成: 1分あたり2クレジット
HIGHLIGHT_DETECTION: 5, // ハイライト検出: 5クレジット固定
EXPORT_HD: 3, // HD書き出し: 3クレジット
EXPORT_4K: 8, // 4K書き出し: 8クレジット
} as const;
export const CREDIT_PACKAGES = [
{ credits: 30, priceJpy: 300, priceId: 'price_XXXXXXXXXX_30' },
{ credits: 100, priceJpy: 800, priceId: 'price_XXXXXXXXXX_100' },
{ credits: 300, priceJpy: 2000, priceId: 'price_XXXXXXXXXX_300' },
] as const;クレジット残高の確認と消費は、Supabase の postgres function でアトミックに行います。残高の確認と減算を別々のクエリに分けると、同時実行のときに残高がマイナスへ振れる競合が起きるためです。
-- supabase/migrations/001_credit_deduct_function.sql
CREATE OR REPLACE FUNCTION deduct_credits(
p_user_id UUID,
p_amount INTEGER,
p_description TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_current_credits INTEGER;
BEGIN
-- 行ロックで競合を防ぐ
SELECT credits INTO v_current_credits
FROM user_credits
WHERE user_id = p_user_id
FOR UPDATE;
IF v_current_credits IS NULL OR v_current_credits < p_amount THEN
RETURN FALSE; -- クレジット不足
END IF;
UPDATE user_credits
SET credits = credits - p_amount,
updated_at = NOW()
WHERE user_id = p_user_id;
-- 使用履歴を記録
INSERT INTO credit_transactions (user_id, amount, description, created_at)
VALUES (p_user_id, -p_amount, p_description, NOW());
RETURN TRUE;
END;
$$;Rork Maxアプリ側からStripe Checkoutを起動してクレジットを購入する流れそのものは、Checkout Sessionの作成をEdge Functionに置き、決済完了のリダイレクトをディープリンクで受け取る構成が扱いやすいです。使用量に応じた課金へ発展させたい場合は、Rork × Stripe Meter で AI アプリの使用量課金を実装するで設計を詳しくまとめています。
よくある実装の落とし穴と対処法
実際に動画編集アプリを作っていくと必ず遭遇する問題を5つ取り上げます。
① 動画の向きが狂う
iOSで撮影した動画はメタデータ(Orientation)で向きを管理しているが、アップロード後にFFmpegで再エンコードすると横向きや逆さになることがあります。これはFFmpegがデフォルトでメタデータを保持しつつも、フィルターを適用すると誤動作するためです。
正しい対処は-vf "transpose=1"などの回転フィルターでピクセルレベルで回転させた上で、-metadata:s:v:0 rotate=0でメタデータの回転値をリセットすることです。メタデータだけ書き換えると再生環境によって無視されることがあります。
② Whisper APIのファイルサイズ上限超過
Whisper APIはファイルの最大サイズが25MBに制限されています。5分の動画でも圧縮率によっては超えることがあります。解決策は、動画をFFmpegで音声トラックだけ抽出(-vn -acodec libmp3lame -q:a 7)してからWhisperに送ることです。300MBの動画でも音声だけなら15MB以下になります。さらに長い場合は10分ごとに分割して複数回呼び出し、VTTを結合します。
③ Supabase Realtimeの接続が切れる
動画処理が10分以上かかる場合、WebSocket接続がタイムアウトして完了通知を受け取れないことがあります。上記コードではポーリング(30秒間隔)をフォールバックとして設けていますが、アプリがバックグラウンドに回った場合はポーリングも止まります。対処として、Edge FunctionがExpo Push Notification APIに完了通知を送る処理を追加すると確実です。
④ Signed URLの期限切れ
処理の途中で Supabase の Signed URL が切れると、Gemini は動画を取得できません。掲載のコードは 7200 秒(2 時間)にしていますが、長い動画では処理を開始する直前に発行し直すほうが安全です。Edge Function から service role で動画を直接読む手もあります。
⑤ 署名付き URL で渡せるのは 100MB まで
これが、この記事でいちばん長く私を止めた箇所です。署名付き URL を file_uri に渡す経路は、リクエストあたり 100MB が上限です。アプリ側で 2GB まで通していた検証は、ここと正面から矛盾していました。上のバリデーションがその矛盾を抱えたままだったので、書き直します。
100MB を超える動画は、Files API へアップロードしてから返ってきた URI を渡す経路に切り替えます。Files API 側は 1 ファイル 2GB まで、保持は 48 時間です。長さの上限(30 分など)は別の理由——コンテキストの量と応答時間——で引き続き必要ですが、バイト数の境界は 100MB のほうが先に来ます。
| 入力方法 | サイズ上限 | 保持 | 使いどころ |
|---|---|---|---|
| インライン(base64 で同梱) | リクエストあたり 100MB | なし | 短いクリップ・検証用 |
| 外部 URL(公開 / 署名付き) | リクエストあたり 100MB | なし(毎回取得) | すでに Storage にある 100MB 以下の動画 |
| Files API へアップロード | 1 ファイル 2GB | 48 時間 | 100MB を超える動画・同じ動画を何度も分析する場合 |
// utils/videoValidation.ts
const MAX_DURATION_SECONDS = 30 * 60; // 長さの上限(応答時間とコスト由来)
const EXTERNAL_URL_LIMIT = 100 * 1024 * 1024; // 署名付き URL 経路の上限
const FILES_API_LIMIT = 2 * 1024 * 1024 * 1024; // Files API の上限
export type HighlightRoute = 'signed-url' | 'files-api';
export interface VideoPlan {
error: string | null;
route: HighlightRoute | null;
}
export function planHighlightRoute(asset: ImagePicker.ImagePickerAsset): VideoPlan {
// asset.duration はミリ秒
if (asset.duration && asset.duration > MAX_DURATION_SECONDS * 1000) {
return {
error: `動画が長すぎます(最大 ${MAX_DURATION_SECONDS / 60} 分)。トリミングしてからお試しください。`,
route: null,
};
}
const size = asset.fileSize ?? 0;
if (size > FILES_API_LIMIT) {
return { error: 'ファイルサイズが大きすぎます(最大 2GB)。', route: null };
}
// 100MB を境に、URL を渡すか Files API へ上げるかを分ける
return { error: null, route: size > EXTERNAL_URL_LIMIT ? 'files-api' : 'signed-url' };
}⑥ URL の取得に失敗しても、応答は返ってくる
もうひとつ、時間を取られた挙動があります。file_uri に渡した URL は安全性の確認を通り、そこで弾かれると url_retrieval_status が URL_RETRIEVAL_STATUS_UNSAFE で返ります。認証が必要なページや期限切れの URL も、ここで落ちます。
厄介なのは、取得に失敗しても HTTP 200 で応答が返り、それらしいハイライトの JSON が入っていることです。動画を見ていないモデルが、プロンプトの指示だけを頼りに 5 件の区間を書いて返してきます。私は、アップロードした覚えのない内容の理由文が並んでいるのを見て、ようやく気づきました。
対処は単純で、応答の url_retrieval_status(またはハイライトの区間が動画の長さを超えていないか)を必ず確かめ、怪しければハイライトなしで確定させることです。取得できたと確かめるまで、返ってきた JSON は信じません。 ここを飛ばすと、原因の分からない苦情だけが残ります。
字幕付き動画の表示と共有
処理が完了したら、字幕を動画に重ねて表示するプレーヤーを実装します。現行のExpo SDKではexpo-avが非推奨となりexpo-videoへの移行が前提になっていますが、expo-videoにもVTT字幕をそのまま重ねて表示する仕組みはありません。VTTをパースし、字幕テキストを再生位置に合わせて自前で描画するカスタム実装が必要です。
// utils/vttParser.ts
export interface Cue {
startTime: number; // 秒
endTime: number;
text: string;
}
export function parseVTT(vttString: string): Cue[] {
const cues: Cue[] = [];
const blocks = vttString.split('\n\n').filter(Boolean);
for (const block of blocks) {
const lines = block.trim().split('\n');
// タイムコード行を探す(例: 00:00:12.500 --> 00:00:15.000)
const timecodeIndex = lines.findIndex(l => l.includes('-->'));
if (timecodeIndex === -1) continue;
const timecodeLine = lines[timecodeIndex];
const [startStr, endStr] = timecodeLine.split('-->').map(s => s.trim());
const parseTime = (t: string): number => {
const parts = t.replace(',', '.').split(':');
const seconds = parseFloat(parts[parts.length - 1]);
const minutes = parseInt(parts[parts.length - 2] || '0', 10);
const hours = parseInt(parts[parts.length - 3] || '0', 10);
return hours * 3600 + minutes * 60 + seconds;
};
const text = lines.slice(timecodeIndex + 1).join('\n').trim();
if (!text) continue;
cues.push({
startTime: parseTime(startStr),
endTime: parseTime(endStr),
text,
});
}
return cues;
}このパーサーを使って、現在の再生位置に対応する字幕を取得します。再生位置の取得には、expo-videoのプレーヤーが発行するtimeUpdateイベントを使います。既定ではこのイベントは無効になっているため、player.timeUpdateEventInterval = 0.1のように間隔(秒)を設定してから購読する点に注意が必要です。私が最初に組んだときはイベントが一度も届かず、字幕が固まったままこの既定値に気づくまで手が止まりました。取得したcurrentTimeでcueを引き、プレーヤーの上に重ねて描画すれば表示は成立します。
ビジネスモデルの設計 — クレジット単価の計算方法
クレジット単価を設定する際は、APIコストから逆算する点が肝心です。
Whisperの料金は1分あたり$0.006(約0.9円)。字幕生成に2クレジット/分を消費する設定なら、1クレジットあたりの価値は0.45円以上でなければ赤字になります。100クレジットを800円(8円/クレジット)で販売すれば、原価の17倍以上のマージンがあります。
Gemini 2.5 Flashの動画分析コストは動画の長さと解像度によるが、5分の動画で概ね$0.01〜$0.05程度。ハイライト検出に5クレジット固定で、1クレジット8円ならほぼ確実に黒字になります。
実際には課金に積極的なヘビーユーザーが少数存在し、無料クレジット(初回登録時に10クレジット付与など)を使って辞めてしまうユーザーが多数います。この構造を前提に、月次で単価の見直しを行うのが現実的です。
もうひとつ、設計段階で決めておきたいのがクレジットを差し引くタイミングです。処理完了時に引く方式は一見親切ですが、Edge Functionが途中で落ちたジョブをリトライしたとき、消費と処理が二重にならない保証を自分で作り込む必要が出てきます。私自身は処理開始時に差し引き、processing_jobsのステータスがfailedになったらcredit_transactionsに打ち消しの行を追加して返却する方式に落ち着きました。失敗時の返却が明細として残るため、「クレジットが減ったのに動画ができていない」という問い合わせに、いつ・どのジョブで・何クレジット戻したかを履歴からそのまま答えられます。
広告と課金の組み合わせ方まで含めた収益設計の全体像は、Rork アプリの収益設計 — 広告と課金が食い合う地点をどう避けるかで別の角度から整理しています。
最初に着手すること
supabase start でローカルの一式を立ち上げ、supabase functions serve process-video で Edge Function をホットリロードに載せたうえで、120MB 前後の動画を 1 本だけ通してみてください。100MB の境界をまたぐ 1 本です。ここを通しておけば、署名付き URL の経路と Files API の経路が両方とも自分の手元で確かめられます。10MB の短いクリップから始めると、いちばん詰まる場所を最後まで見ずに済んでしまうのです。
分割と再開はプロトコルに預け、境界の判断だけは自分のコードに残す——この配り方は、次に別のアプリを作るときにも持っていくつもりです。
お読みいただきありがとうございました。100MB の壁に私と同じ形で足を取られる方が、ひとりでも減れば嬉しく思います。