FFmpegCloudflare2025/8/2210分で読める
💺

動画アップロードから縦型変換まで - Web動画変換サービスの実装解説

A

Anonymous

AI開発エンジニア

フォロワー 12.5K

動画アップロードから縦型変換まで - Web動画変換サービスの実装解説

はじめに

TikTok、Instagram Reels、YouTube Shortsなど、縦型動画コンテンツの需要が急速に高まっています。今回は、横長動画を縦型フォーマットに変換するWebサービスの実装について、フロントエンドからバックエンドまでの全体的な流れを解説します。

技術スタック

フロントエンド

  • React 19 + TypeScript - UIフレームワーク
  • Vite - ビルドツール
  • Tailwind CSS - スタイリング

バックエンド

  • Cloudflare Workers - サーバーレス実行環境
  • Hono.js - 軽量Webフレームワーク
  • Cloudflare D1 - SQLiteベースのデータベース
  • Cloudflare R2 - オブジェクトストレージ

1. 動画アップロード機能の実装

1.1 フロントエンド - ドラッグ&ドロップUI

// FileUpload.tsxexport const FileUpload: React.FC<FileUploadProps> = ({
  onFileSelect,
  maxSizeMB = 50
}) => {
  const [isDragging, setIsDragging] = useState(false);

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    const file = e.dataTransfer.files[0];

// ファイル検証if (!file.type.startsWith('video/')) {
      setError('動画ファイルを選択してください');
      return;
    }

    if (file.size > maxSizeMB * 1024 * 1024) {
      setError(`ファイルサイズは${maxSizeMB}MB以下にしてください`);
      return;
    }

    onFileSelect(file);
  };
typescript

ポイント:

  • ドラッグ&ドロップで直感的な操作
  • ファイルタイプとサイズの事前検証
  • エラーハンドリングでユーザー体験を向上

1.2 バックエンド - アップロードAPI

// backend/src/api/video.ts
videoRoutes.post('/upload', async (c) => {
  const formData = await c.req.formData();
  const file = formData.get('file') as File;
  const format = formData.get('format') as '9:16' | '1:1';
  const startTime = formData.get('startTime');
  const endTime = formData.get('endTime');

// ファイルサイズチェックconst maxSize = userPlan === 'pro' ? 500 * 1024 * 1024 : 50 * 1024 * 1024;
  if (file.size > maxSize) {
    throw new AppError(400, 'File size exceeds limit', 'FILE_TOO_LARGE');
  }

// R2ストレージへアップロードconst jobId = crypto.randomUUID();
  const storagePath = `uploads/${userId}/${jobId}/${file.name}`;
  await storage.uploadFromFormData(storagePath, file, {
    jobId,
    format,
    startTime,
    endTime
  });

// データベースにジョブ情報を保存await db.createConversionJob({
    id: jobId,
    inputFileName: file.name,
    inputFileSize: file.size,
    outputFormat: format,
    startTime,
    endTime,
    clipDuration: endTime - startTime
  });

  return c.json({ jobId, status: 'pending' });
});
typescript

2. 時間範囲選択機能

長い動画から必要な部分だけを切り抜くための機能です。

2.1 TimeRangeSelector コンポーネント

// TimeRangeSelector.tsxexport const TimeRangeSelector: React.FC<Props> = ({ file, onRangeSelect }) => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [startTime, setStartTime] = useState(0);
  const [endTime, setEndTime] = useState(60);

// ビデオプレビューconst videoUrl = useMemo(() => URL.createObjectURL(file), [file]);

// 選択範囲のプレビュー再生const playPreview = () => {
    if (videoRef.current) {
      videoRef.current.currentTime = startTime;
      videoRef.current.play();

// 終了時刻で自動停止const checkTime = setInterval(() => {
        if (videoRef.current.currentTime >= endTime) {
          videoRef.current.pause();
          clearInterval(checkTime);
        }
      }, 100);
    }
  };

  return (
    <div>
      <video ref={videoRef} src={videoUrl} />

      {/* スライダーで範囲選択 */}
      <input
        type="range"
        min="0"
        max={videoDuration}
        value={startTime}
        onChange={(e) => setStartTime(parseFloat(e.target.value))}
      />

      <Button onClick={() => onRangeSelect(startTime, endTime)}>
        この範囲で決定
      </Button>
    </div>
  );
};
typescript

実装のポイント:

  • URL.createObjectURLでローカルプレビュー
  • スライダーで直感的な範囲選択
  • リアルタイムプレビュー機能

3. 変換処理の実装

3.1 変換ジョブの開始

// backend/src/api/video.ts
videoRoutes.post('/convert/:jobId', async (c) => {
  const jobId = c.req.param('jobId');
  const job = await db.getConversionJob(jobId);

  if (job.status !== 'pending') {
    throw new AppError(400, 'Job already processing');
  }

// ステータスを更新await db.updateConversionJobStatus(jobId, 'processing');

// FFmpeg処理(実装例)const command = `
    ffmpeg -ss ${job.start_time}
           -t ${job.clip_duration}
           -i input.mp4
           -vf "crop=ih*9/16:ih"
           -c:a copy
           output.mp4
  `;

// 実際の処理はWorkerやQueue経由で実行await processVideo(jobId, command);

  return c.json({ status: 'processing' });
});
typescript

3.2 フロントエンドでの進捗管理

// HomePage.tsxconst handleStartConversion = async (jobId: string) => {
// 変換開始await apiClient.startConversion(jobId);

// ステータスをポーリングconst checkStatus = setInterval(async () => {
    const status = await apiClient.getConversionStatus(jobId);

    if (status.status === 'completed') {
      clearInterval(checkStatus);
      setDownloadUrl(status.downloadUrl);
      setShowDownloadModal(true);
    } else if (status.status === 'failed') {
      clearInterval(checkStatus);
      setError('変換に失敗しました');
    }
  }, 2000);// 2秒ごとにチェック
};
typescript

4. データベース設計

-- conversion_jobs テーブルCREATE TABLE conversion_jobs (
  id TEXT PRIMARY KEY,-- UUID
  user_id INTEGER,-- ユーザーID
  status TEXT DEFAULT 'pending',-- pending/processing/completed/failed
  input_file_name TEXT NOT NULL,-- 元ファイル名
  input_file_size INTEGER NOT NULL,-- ファイルサイズ(bytes)
  output_format TEXT,-- '9:16' or '1:1'
  start_time REAL,-- 切り抜き開始時刻(秒)
  end_time REAL,-- 切り抜き終了時刻(秒)
  clip_duration REAL,-- 切り抜き時間(秒)
  processing_time INTEGER,-- 処理時間(ms)
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  completed_at DATETIME
);
sql

5. 実装上の課題と解決策

5.1 大容量ファイルの処理

課題: ブラウザのメモリ制限(通常2GB程度)

解決策:

  • チャンク単位でのアップロード
  • ストリーミング処理
  • プログレッシブアップロード
// チャンクアップロードの例async function uploadInChunks(file: File, chunkSize = 5 * 1024 * 1024) {
  const totalChunks = Math.ceil(file.size / chunkSize);

  for (let i = 0; i < totalChunks; i++) {
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);

    await uploadChunk(chunk, i, totalChunks);
  }
}
typescript

5.2 Cloudflare Workers の制限

制限事項:

  • CPU時間: 10ms (無料) / 30秒 (有料)
  • メモリ: 128MB
  • リクエストサイズ: 100MB

対策:

  • 重い処理は Queue や Durable Objects を活用
  • FFmpeg処理は外部APIサービスを利用
  • または、ブラウザ側でFFmpeg.wasmを使用

5.3 FFmpeg.wasm を使った ブラウザ内変換

// FFmpeg.wasm の実装例import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';

const ffmpeg = createFFmpeg({ log: true });

async function convertVideo(file: File, startTime: number, duration: number) {
  if (!ffmpeg.isLoaded()) {
    await ffmpeg.load();
  }

// ファイルをFFmpegの仮想ファイルシステムに書き込み
  ffmpeg.FS('writeFile', 'input.mp4', await fetchFile(file));

// 変換コマンド実行await ffmpeg.run(
    '-i', 'input.mp4',
    '-ss', startTime.toString(),
    '-t', duration.toString(),
    '-vf', 'crop=ih*9/16:ih',// 9:16にクロップ'-c:a', 'copy',
    'output.mp4'
  );

// 変換後のファイルを取得const data = ffmpeg.FS('readFile', 'output.mp4');
  return new Blob([data.buffer], { type: 'video/mp4' });
}
typescript

6. パフォーマンス最適化

6.1 プレビューの最適化

// サムネイル生成で軽量プレビューasync function generateThumbnail(videoFile: File): Promise<string> {
  const video = document.createElement('video');
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  return new Promise((resolve) => {
    video.onloadeddata = () => {
      canvas.width = video.videoWidth;
      canvas.height = video.videoHeight;
      ctx.drawImage(video, 0, 0);
      resolve(canvas.toDataURL('image/jpeg'));
    };

    video.src = URL.createObjectURL(videoFile);
    video.currentTime = 1;// 1秒目のフレーム
  });
}
typescript

6.2 キャッシュ戦略

// Service Worker でのキャッシュ
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/video/download/')) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request).then((fetchResponse) => {
          return caches.open('video-cache').then((cache) => {
            cache.put(event.request, fetchResponse.clone());
            return fetchResponse;
          });
        });
      })
    );
  }
});
typescript

7. セキュリティ考慮事項

7.1 ファイル検証

// MIMEタイプとマジックナンバーの検証function validateVideoFile(file: File): boolean {
  const validTypes = ['video/mp4', 'video/quicktime'];

// MIMEタイプチェックif (!validTypes.includes(file.type)) {
    return false;
  }

// マジックナンバーチェック(最初の数バイト)return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onloadend = () => {
      const arr = new Uint8Array(reader.result as ArrayBuffer);
// MP4: 0x00000018 or 0x00000020 at offset 4const isMP4 = arr[4] === 0x66 && arr[5] === 0x74 &&
                    arr[6] === 0x79 && arr[7] === 0x70;
      resolve(isMP4);
    };
    reader.readAsArrayBuffer(file.slice(0, 8));
  });
}
typescript

7.2 レート制限

// Rate Limiter の実装export const rateLimiters = {
  upload: rateLimit({
    windowMs: 60 * 1000,// 1分max: 5,// 最大5回message: 'Too many uploads, please try again later'
  }),

  conversion: rateLimit({
    windowMs: 60 * 1000,
    max: 10,
    keyGenerator: (req) => req.ip// IPベース
  })
};
typescript

まとめ

動画変換サービスの実装では、以下の点が重要です:

  1. ユーザー体験 - 直感的なUIと適切なフィードバック
  2. パフォーマンス - チャンク処理とキャッシュ戦略
  3. スケーラビリティ - サーバーレスアーキテクチャの活用
  4. セキュリティ - ファイル検証とレート制限

今回の実装では、Cloudflare WorkersとR2を活用することで、低コストでスケーラブルなサービスを構築できました。FFmpeg.wasmを使用すれば、サーバーレスでも本格的な動画処理が可能です。

今後の展望

  • AI機能の追加 - 自動シーン検出、最適な切り抜き位置の提案
  • リアルタイム処理 - WebRTCを使ったストリーミング変換
  • モバイル対応 - React NativeやCapacitorでのネイティブアプリ化
  • バッチ処理 - 複数動画の一括変換

動画コンテンツの需要は今後も拡大が予想されるため、このような変換サービスの重要性はますます高まっていくでしょう。

このドキュメントで一部のコンテンツが無効になっています

著者について

A

Anonymous

AI開発エンジニア。生成AIを活用した開発手法の研究と実践に従事。 最新のAI技術を使った効率的な開発方法を日々探求しています。