RORK LABEN
TOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しましたTOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しました
記事一覧/AIモデル
AIモデル/2026-04-14上級

Rork Max で作るAI動画編集アプリ実装ガイド — 字幕自動生成・ハイライト抽出・Stripe課金を一気通貫で実装する

Rork MaxでAI動画編集アプリを構築する完全ガイド。Whisper APIによる字幕自動生成、Gemini Flashによるハイライトシーン検出、Supabase Storageへの動画アップロード、Stripe課金モデルまで実装例付きで解説します。

Rork Max229AI動画編集Whisper APISupabase33Stripe17動画アプリ字幕生成

ショート動画が主流になった今、「スマートフォンで撮った動画をAIが自動でまとめてくれる」アプリへの需要は急速に高まっています。しかし、個人開発者がこの市場に参入するには技術的なハードルが高く、「動画処理をサーバーでどう実装するか」「AIの字幕生成と動画編集をどう繋ぐか」という問いに詰まるケースが多いです。

このガイドでは、Rork Maxを起点にして、動画アップロードから字幕自動生成・ハイライト検出・Stripe課金まで動くプロダクトを作る一連の実装を解説します。コピー&ペーストで動くコードと、実際の開発で踏んだ罠の対処法を交えながら進めていく。

なぜ今AI動画編集アプリなのか — 市場の現実と個人開発者の勝ち筋

動画編集市場を見ると、CapCutやVLLOが圧倒的シェアを持つ一方で、「特定の用途に特化したAI編集ツール」には明確な隙間があります。たとえば「会議録画から自動でハイライトを切り出す」「旅行動画に多言語字幕を一括生成する」「料理動画を工程ごとに分割する」といったニッチな機能は、大手アプリでは実装が遅く、個人開発者が先行できる領域です。

Rork Maxを使うと、ネイティブiOSアプリとしてのUIを素早く生成しながら、バックエンドをCloudflare WorkersやSupabaseで組むことができます。動画処理の重い部分はサーバー側に委ね、アプリ側はアップロードと結果表示に集中します。この責任分担が、個人開発で動画アプリを作る際の現実的な正解です。

重要な前提として、Rork Maxアプリ側でFFmpegを動かそうとしてはいけない。React Nativeではネイティブモジュールを介した動画処理が可能ですが、バイナリサイズが大幅に増加し、審査でリジェクトされるリスクもあります。サーバーサイドで処理するアーキテクチャが唯一の現実解です。

アーキテクチャの設計 — 処理の流れとスタックの選定理由

今回実装するシステムの全体像はこうなります。

  1. ユーザーが動画を選択するとRork Maxアプリが動画をSupabase Storageにチャンクアップロードする
  2. アップロード完了後、アプリがSupabase Edge Functionに処理開始をリクエストする
  3. Edge FunctionがOpenAI Whisper APIを呼んで文字起こしを行い、同時にGemini 2.5 Flash APIで動画のハイライトシーンを分析する
  4. FFmpegでクリップをトリミングして字幕をオーバーレイする
  5. 完成した動画はSupabase StorageのPublic URLで配信される
  6. アプリがSupabase Realtimeで処理完了通知を受け取り、結果を表示する

なぜSupabaseを選んだか。Storage・Database・Edge Functions・Realtimeが一体で使えるため、動画処理の完了通知をリアルタイムでアプリに伝えるのが簡単だからです。AlternativeとしてCloudflare R2 + Workers AIも検討したが、動画のチャンクアップロードのSDKサポートという点でSupabaseが現時点では優位だった。

技術スタックの全体像

使用するライブラリとサービスをまとめる。

  • フロントエンド(Rork Max生成): expo-image-picker, expo-av, 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)

動画アップロードの実装 — チャンク分割と進捗表示

動画ファイルは数十MBから数GBに及ぶため、単純な一括アップロードは失敗リスクが高いです。Supabase StorageのTUS(Resumable Upload)対応を使ってチャンクアップロードを実装します。

まずアップロードサービスを作ります。

// services/videoUpload.ts
import * as FileSystem from 'expo-file-system';
import { supabase } from './supabaseClient';
 
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MBごとに分割
 
export async function uploadVideoWithProgress(
  videoUri: string,
  fileName: string,
  onProgress: (progress: number) => void
): Promise<{ path: string; error: Error | null }> {
  try {
    const fileInfo = await FileSystem.getInfoAsync(videoUri);
    if (\!fileInfo.exists) {
      throw new Error('動画ファイルが見つかりません');
    }
 
    // Androidでは型アサーションが必要
    const fileSizeBytes = (fileInfo as FileSystem.FileInfo & { size: number }).size;
    const totalChunks = Math.ceil(fileSizeBytes / CHUNK_SIZE);
    const storagePath = `videos/${Date.now()}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
 
    for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
      const start = chunkIndex * CHUNK_SIZE;
      const end = Math.min(start + CHUNK_SIZE, fileSizeBytes);
 
      // Base64でチャンクを読み込む
      const chunk = await FileSystem.readAsStringAsync(videoUri, {
        encoding: FileSystem.EncodingType.Base64,
        position: start,
        length: end - start,
      });
 
      const chunkBuffer = Buffer.from(chunk, 'base64');
 
      const { error: uploadError } = await supabase.storage
        .from('video-uploads')
        .upload(storagePath, chunkBuffer, {
          contentType: 'video/mp4',
          upsert: chunkIndex > 0,
          duplex: 'half',
        });
 
      if (uploadError && uploadError.message \!== 'The object already exists') {
        throw new Error(`チャンク${chunkIndex + 1}/${totalChunks}のアップロードに失敗: ${uploadError.message}`);
      }
 
      const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100);
      onProgress(progress);
    }
 
    return { path: storagePath, error: null };
 
  } catch (err) {
    const error = err instanceof Error ? err : new Error('アップロード中に不明なエラーが発生しました');
    return { path: '', error };
  }
}

次に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 { uploadVideoWithProgress } from '../services/videoUpload';
import { triggerVideoProcessing } from '../services/videoProcessing';
 
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: ImagePicker.MediaTypeOptions.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;
    }
 
    setUploading(true);
    setStatusMessage('動画をアップロード中...');
 
    const { path, error: uploadError } = await uploadVideoWithProgress(
      asset.uri,
      asset.fileName,
      (p) => {
        setProgress(p);
        setStatusMessage(`アップロード中... ${p}%`);
      }
    );
 
    if (uploadError) {
      setError(`アップロードに失敗しました: ${uploadError.message}`);
      setUploading(false);
      return;
    }
 
    setStatusMessage('AI処理を開始中...');
 
    const { jobId, error: processingError } = await triggerVideoProcessing(path);
    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' },
});

アップロードの実装で陥りやすい罠が一点あります。iOSのPhotoKitで選択した動画のURIはph://スキームになっているケースがあり、FileSystem.readAsStringAsyncに直接渡せありません。expo-image-pickerはデフォルトでローカルコピーのURIを返すが、バージョンによってはcopyToCacheDirectory: trueオプションを明示しないとph://URIが返ることがあります。必ずlaunchImageLibraryAsyncのオプションにcopyToCacheDirectory: trueを設定することを推奨します。

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' },
    });
  }
});

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を起動してクレジットを購入する流れはRork Stripe サブスクリプション完全ガイドで詳しく解説しているので参照されたい。

よくある実装の落とし穴と対処法

実際に動画編集アプリを作っていくと必ず遭遇する問題を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 APIが動画を取得できなくなります。上記コードでは7200秒(2時間)に設定しているが、さらに長い動画に対応する場合は処理を開始する直前にSigned URLを再発行するよう実装を変えること。あるいはEdge FunctionからSupabaseのservice roleを使って動画を直接読む方法もあります。

⑤ Gemini APIの動画コンテキストウィンドウ超過

Gemini 2.5 Flashは1時間程度の動画は扱えるが、それ以上の動画はコンテキストウィンドウを超過してエラーになります。ユーザーにアップロード可能な動画の上限(例: 30分または2GB)を明示し、アプリ側でバリデーションする点が肝心です。

// utils/videoValidation.ts
const MAX_DURATION_SECONDS = 30 * 60; // 30分
const MAX_FILE_SIZE_MB = 2 * 1024; // 2GB
 
export function validateVideo(asset: ImagePicker.ImagePickerAsset): string | null {
  if (asset.duration && asset.duration > MAX_DURATION_SECONDS * 1000) {
    return `動画が長すぎます(最大${MAX_DURATION_SECONDS / 60}分)。トリミングしてから再試行してください。`;
  }
  if (asset.fileSize && asset.fileSize > MAX_FILE_SIZE_MB * 1024 * 1024) {
    return `ファイルサイズが大きすぎます(最大${MAX_FILE_SIZE_MB}MB)。`;
  }
  return null;
}

字幕付き動画の表示と共有

処理が完了したら、字幕を動画に重ねて表示するプレーヤーを実装します。expo-avは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;
}

このパーサーを使って現在の再生位置に対応する字幕を取得し、動画プレーヤーの上に重ねて表示します。

ビジネスモデルの設計 — クレジット単価の計算方法

クレジット単価を設定する際は、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クレジット付与など)を使って辞めてしまうユーザーが多数います。この構造を前提に、月次で単価の見直しを行うことをすすめる。

収益化のさらなる深掘りには、AI機能を活用した課金設計の実践例をRork AIアプリ収益化完全ガイドでまとめているので、あわせて参考にしていただきたい。

全体を振り返って — 動画アプリを動かすために最初に着手すること

このガイドで実装した内容を振り返ると、Supabaseへのチャンクアップロード・Whisperによる字幕生成・Gemini Flashによるハイライト検出・Realtimeによる完了通知・Stripeクレジット課金という5つのパイプラインが動く状態になりました。

次のアクションとして、まずSupabaseのローカル開発環境を立ち上げてEdge Functionを動かしてみることをすすめる。supabase startでローカルの全サービスが起動し、Edge Functionをsupabase functions serve process-videoでホットリロード可能な状態でテストできます。APIキーはSupabase DashboardのProject Settings → APIから取得します。

動画編集アプリは、一度動くパイプラインができると「このAI機能を追加したらどうか」というアイデアが次々と生まれます。字幕の多言語翻訳・BGM自動選曲・サムネイル自動生成など、拡張の方向は無数にあります。まずは一つの機能を動かすことに集中して、そこから育てていこう。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

AI モデル2026-04-17
プッシュ通知を『送るのをやめた』ら離脱率が下がった話 — Rork Max で作るAIチャーン予測通知システムの全設計
ユーザー行動データからチャーン予兆を数値化し、Claude APIで個別最適化した通知文を生成、OneSignalで配信する完全システムをRork Maxで実装。コード付き実践ガイド。
AI モデル2026-04-10
Rork × AI パーソナライゼーションエンジン実装ガイド — ユーザー行動を学習してUI・コンテンツ・通知を自動最適化するインテリジェントアプリの設計
Rork MaxでAI駆動のパーソナライゼーションエンジンを構築する上級ガイド。ユーザー行動のリアルタイム学習、UIの動的最適化、コンテンツの個別配信、通知タイミングの自動調整まで、壁紙系・癒し系のアプリを個人で運用してきた経験から、本番で効いた実装パターンと、ドキュメントに載らない運用上の判断を具体的に共有します。
AI モデル2026-03-29
Rork × RAG パターン — モバイルAIチャットにナレッジベースを統合する設計と実装
RAG(検索拡張生成)をモバイルアプリに実装する方法を解説。Rork × Supabase pgvector × Gemini APIで、独自ナレッジに基づくAIチャット機能を構築する全手順を紹介します。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →