← All posts

UGC プラットフォームの動画処理 — 実装ガイド

ユーザーは何でも投げてくる。フォーマット正規化、サムネ、マルチ解像度、スケールするアーキテクチャ——本番投入できるパイプラインを実コマンド付きで。

FFHub·2026-05-11
UGC プラットフォームの動画処理 — 実装ガイド

ユーザーは本当に何でもアップロードしてきます。一眼レフ由来の 4K ProRes ファイル。WebM の縦向き画面録画。ガラケーで撮った 10 年前の AVI。HEVC 入りの MOV——Safari 以外じゃ再生できないやつ。プラットフォーム側はそれを全部受け入れて、どんなデバイス・回線条件でも一貫した再生体験を返さなきゃいけない。

この記事では UGC プラットフォーム向けの動画処理パイプラインを、アップロードから配信まで通して扱います。実コマンドとスケールするアーキテクチャパターンつき。

UGC 動画処理パイプライン

ユーザーアップロード動画を扱うすべてのプラットフォームに、何らかのバージョンのこのパイプラインがあります:

┌─────────┐   ┌──────────┐   ┌────────────┐   ┌────────────┐   ┌──────────┐
│  Upload  │──>│ Validate │──>│ Normalize  │──>│ Generate   │──>│ Deliver  │
│          │   │ & Store  │   │ Format     │   │ Variants   │   │ via CDN  │
└─────────┘   └──────────┘   └────────────┘   └────────────┘   └──────────┘
                                    │                │
                              ┌─────┴─────┐    ┌────┴─────┐
                              │ Transcode │    │ Thumbnails│
                              │ to H.264  │    │ Previews  │
                              │ MP4       │    │ Multi-res  │
                              └───────────┘    └──────────┘

各ステージに固有の技術要件があります。順に見ていきます。

ステージ 1:アップロードと検証

トランスコードに計算リソースを使う前に、まずアップロードを検証する:

ファイルレベルのチェック

// 上传验证中间件
function validateUpload(req, res, next) {
  const file = req.file;

  // 文件大小限制
  const MAX_SIZE = 2 * 1024 * 1024 * 1024; // 2GB
  if (file.size > MAX_SIZE) {
    return res.status(413).json({ error: "File exceeds 2GB limit" });
  }

  // MIME 类型白名单
  const ALLOWED_TYPES = [
    "video/mp4", "video/quicktime", "video/x-msvideo",
    "video/webm", "video/x-matroska", "video/x-flv",
  ];
  if (!ALLOWED_TYPES.includes(file.mimetype)) {
    return res.status(415).json({ error: "Unsupported video format" });
  }

  next();
}

ファイルを probe する

アップロード後、処理前に FFprobe でメタデータを取る:

ffprobe -v quiet -print_format json -show_format -show_streams input.mp4

長さ、解像度、コーデック、ビットレート、フレームレート——全部後段のトランスコード判断に使えます。

// 使用 FFprobe 获取视频元数据
async function probeVideo(fileUrl) {
  const response = await fetch("https://api.ffhub.io/v1/tasks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      command: `ffprobe -v quiet -print_format json -show_format -show_streams ${fileUrl}`,
    }),
  });
  const { task_id } = await response.json();
  // 等待完成后解析 JSON 输出
  return await waitAndGetResult(task_id);
}

ステージ 2:フォーマット正規化

ゴール:どんな入力フォーマットからも一貫した出力を作る。UGC プラットフォームの大半においてそれは H.264 MP4 + AAC 音声 です。

なぜ H.264 MP4 か

観点H.264 MP4H.265VP9/WebMAV1
ブラウザサポート99%+約 80%約 90%約 70%
モバイルサポートほぼすべてiOS のみAndroid が中心限定的
エンコード速度高速2〜3 倍遅い3〜5 倍遅い10 倍遅い
ハードウェアデコードほぼすべて新しめのデバイスChrome/Android最新のみ
ファイルサイズ(基準)1 倍0.5 倍0.6 倍0.4 倍

H.264 は互換性で勝つ。グローバルかつ古いデバイスも視聴対象なら、安全なデフォルトはこれしかありません。対応クライアント向けに H.265 や AV1 を Progressive Enhancement として足す形になります。圧縮設定の詳しい話は FFmpeg 動画圧縮のベストプラクティス を参照。

ベースとなるトランスコードコマンド

ffmpeg -i input.mov \
  -c:v libx264 \
  -crf 23 \
  -preset medium \
  -profile:v high \
  -level 4.1 \
  -pix_fmt yuv420p \
  -c:a aac \
  -b:a 128k \
  -ar 44100 \
  -movflags +faststart \
  output.mp4

各フラグの意味:

  • -crf 23:UGC コンテンツでの品質とサイズのバランスが良い
  • -profile:v high -level 4.1:モダンデバイスとの最大互換性
  • -pix_fmt yuv420p:多くのデバイスで再生に必須(一部カメラは yuv422p や yuv444p を出力する)
  • -movflags +faststart:メタデータをファイル先頭に移動。フルダウンロードを待たずに再生開始できる——Web 配信では必須

回転の扱い

スマホ動画は「回転メタデータ」を持っているだけで、ピクセルは物理的に回転していないことが多い。-c:v libx264 で FFmpeg が自動処理してくれますが、強制したいなら:

ffmpeg -i input.mp4 -c:v libx264 -crf 23 -vf "transpose=1" output.mp4

解像度の上限

ユーザーは 4K を投げてくるが、プレイヤーは 1080p までしか出さない。小さい動画はアップスケールせず、上限だけかけたい:

ffmpeg -i input.mp4 \
  -vf "scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2" \
  -c:v libx264 -crf 23 -preset medium \
  -c:a aac -b:a 128k \
  output.mp4

1920x1080 を超えるものは縮小、それ以下はそのまま、寸法は偶数(H.264 要件)に揃えます。

ステージ 3:サムネイルとプレビュー生成

すべての動画にサムネイルが要ります。多くのプラットフォームはホバー用のアニメーションプレビューも作る。

静的サムネイル

2 秒地点でフレームを抽出(イントロの黒画面を避ける):

ffmpeg -i input.mp4 -ss 2 -frames:v 1 -q:v 2 thumbnail.jpg

複数候補

5 つの等間隔フレームを抽出してユーザーに選ばせる(または画質スコアラーで自動選択する):

ffmpeg -i input.mp4 \
  -vf "select='not(mod(n\,floor($(ffprobe -v error -count_frames -select_streams v:0 -show_entries stream=nb_read_frames -of csv=p=0 input.mp4)/5)))',scale=640:-2" \
  -frames:v 5 -vsync vfr \
  thumb_%02d.jpg

シンプルに、特定タイムスタンプで抽出するアプローチ:

# 在 1s, 25%, 50%, 75% 处分别截取缩略图
ffmpeg -i input.mp4 -ss 1 -frames:v 1 thumb_01.jpg
ffmpeg -i input.mp4 -ss 25% -frames:v 1 thumb_02.jpg
ffmpeg -i input.mp4 -ss 50% -frames:v 1 thumb_03.jpg
ffmpeg -i input.mp4 -ss 75% -frames:v 1 thumb_04.jpg

アニメーションプレビュー(GIF or WebP)

ホバー用に 3〜5 秒のループプレビュー:

# 从第 5 秒开始截取 4 秒,生成 10fps 320px 宽的 GIF
ffmpeg -i input.mp4 -ss 5 -t 4 \
  -vf "fps=10,scale=320:-2:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" \
  preview.gif

品質とサイズなら WebP のほうが優秀:

ffmpeg -i input.mp4 -ss 5 -t 4 \
  -vf "fps=10,scale=320:-2" \
  -c:v libwebp -lossless 0 -quality 50 -loop 0 \
  preview.webp

ステージ 4:マルチ解像度出力

ABR ストリーミング用に、ソースから複数解像度を生成:

# 1080p
ffmpeg -i input.mp4 -vf "scale=-2:1080" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k output_1080p.mp4

# 720p
ffmpeg -i input.mp4 -vf "scale=-2:720" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 96k output_720p.mp4

# 480p
ffmpeg -i input.mp4 -vf "scale=-2:480" -c:v libx264 -crf 26 -preset medium -c:a aac -b:a 64k output_480p.mp4

# 360p
ffmpeg -i input.mp4 -vf "scale=-2:360" -c:v libx264 -crf 28 -preset medium -c:a aac -b:a 48k output_360p.mp4

低解像度ほど CRF を上げ、音声ビットレートを下げているのに注目。低解像度は視覚情報が少ないぶん、より積極的に圧縮できる。

解像度ラダー

解像度CRF動画ビットレート(典型)音声ビットレート想定回線
1080p233〜5 Mbps128kWiFi / ブロードバンド
720p231.5〜3 Mbps96k良いモバイル
480p260.5〜1.5 Mbps64k遅いモバイル
360p280.3〜0.7 Mbps48k非常に遅い回線

完全な処理パイプライン

Node.js でアップロードから全出力までオーケストレーション:

// ugc-pipeline.js
const API_BASE = "https://api.ffhub.io/v1";
const API_KEY = process.env.FFHUB_API_KEY;

// 提交 FFmpeg 任务并等待完成
async function runFFmpeg(command) {
  const res = await fetch(`${API_BASE}/tasks`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ command }),
  });
  const { task_id } = await res.json();
  return waitForTask(task_id);
}

async function waitForTask(taskId) {
  while (true) {
    const res = await fetch(`${API_BASE}/tasks/${taskId}`, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    });
    const task = await res.json();
    if (task.status === "completed") return task;
    if (task.status === "failed") throw new Error(task.error);
    await new Promise((r) => setTimeout(r, 3000));
  }
}

// UGC 视频处理主流程
async function processUGCVideo(sourceUrl, videoId) {
  console.log(`[${videoId}] 开始处理: ${sourceUrl}`);

  // 步骤 1: 探测源文件
  const probe = await runFFmpeg(
    `ffprobe -v quiet -print_format json -show_format -show_streams ${sourceUrl}`
  );
  const metadata = JSON.parse(probe.output);
  console.log(`[${videoId}] 源文件: ${metadata.format.duration}s, ${metadata.streams[0].width}x${metadata.streams[0].height}`);

  // 步骤 2: 标准化转码 + 缩略图(并行执行)
  const baseCommand = `-c:v libx264 -crf 23 -preset medium -profile:v high -pix_fmt yuv420p -c:a aac -b:a 128k -movflags +faststart`;

  const [normalized, thumbnail, preview] = await Promise.all([
    // 标准化为 1080p H.264 MP4
    runFFmpeg(
      `ffmpeg -i ${sourceUrl} -vf "scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2" ${baseCommand} output.mp4`
    ),
    // 缩略图
    runFFmpeg(
      `ffmpeg -i ${sourceUrl} -ss 2 -frames:v 1 -q:v 2 thumbnail.jpg`
    ),
    // 动画预览
    runFFmpeg(
      `ffmpeg -i ${sourceUrl} -ss 5 -t 4 -vf "fps=10,scale=320:-2" -c:v libwebp -lossless 0 -quality 50 -loop 0 preview.webp`
    ),
  ]);

  // 步骤 3: 生成低分辨率版本(并行)
  const [res720, res480] = await Promise.all([
    runFFmpeg(
      `ffmpeg -i ${sourceUrl} -vf "scale=-2:720" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 96k -movflags +faststart output_720p.mp4`
    ),
    runFFmpeg(
      `ffmpeg -i ${sourceUrl} -vf "scale=-2:480" -c:v libx264 -crf 26 -preset medium -c:a aac -b:a 64k -movflags +faststart output_480p.mp4`
    ),
  ]);

  return {
    videoId,
    outputs: {
      "1080p": normalized.output_url,
      "720p": res720.output_url,
      "480p": res480.output_url,
      thumbnail: thumbnail.output_url,
      preview: preview.output_url,
    },
  };
}

// 使用示例
processUGCVideo("https://your-bucket.s3.amazonaws.com/uploads/raw-video.mov", "vid_abc123")
  .then((result) => console.log("处理完成:", result))
  .catch((err) => console.error("处理失败:", err));

アーキテクチャ:アップロードから配信まで

UGC プラットフォームの本番アーキテクチャ全体像:

┌──────────┐     ┌──────────────┐     ┌──────────────────┐
│  Client  │────>│  Your API    │────>│  Object Storage  │
│  Upload  │     │  Server      │     │  (S3 / R2)       │
└──────────┘     └──────┬───────┘     └────────┬─────────┘
                        │                      │
                        │ POST /tasks          │ source URL
                        v                      │
                 ┌──────────────┐              │
                 │  Transcoding │<─────────────┘
                 │  API         │
                 └──────┬───────┘
                        │
                        │ webhook callback
                        v
                 ┌──────────────┐     ┌──────────────────┐
                 │  Your API    │────>│  CDN              │
                 │  (webhook)   │     │  (CloudFront/CF) │
                 └──────────────┘     └──────────────────┘

フロー

  1. クライアントが API サーバー(または Presigned URL でオブジェクトストレージ直接)に生動画をアップロード
  2. API サーバーが生ファイルを保存し、処理ジョブを作成
  3. トランスコード API がソース URL 入りの FFmpeg コマンドを受け取り、処理
  4. Webhook が処理完了を通知、出力 URL を渡す
  5. API サーバーが DB を更新し、CDN 経由で動画を提供

DB スキーマ

CREATE TABLE videos (
  id          TEXT PRIMARY KEY,
  user_id     TEXT NOT NULL,
  title       TEXT,
  status      TEXT DEFAULT 'uploading',  -- uploading, processing, ready, failed
  source_url  TEXT,
  duration    REAL,
  width       INTEGER,
  height      INTEGER,
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  processed_at TIMESTAMPTZ
);

CREATE TABLE video_variants (
  id          TEXT PRIMARY KEY,
  video_id    TEXT NOT NULL,
  resolution  TEXT NOT NULL,    -- '1080p', '720p', '480p', 'thumbnail', 'preview'
  url         TEXT NOT NULL,
  file_size   BIGINT,
  codec       TEXT,
  bitrate     INTEGER,
  created_at  TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_videos_user ON videos(user_id);
CREATE INDEX idx_variants_video ON video_variants(video_id);

スケール時のパフォーマンス

処理時間ベンチマーク

ソース動画長1080p 出力サムネイルパイプライン全体
1080p 30s クリップ30s約 15s<1s約 25s
1080p 5 分動画5 分約 90s<1s約 120s
4K 10 分動画10 分約 300s<1s約 360s
720p 1 分クリップ1 分約 20s<1s約 30s

これは目安——実際はソースのコーデック、ビットレート、複雑度で変わります。

スケーリング戦略

1 日 100 アップロード: シングルスレッド処理 + シンプルなジョブキュー。DB ベースの素朴なキューで十分。

1 日 1,000 アップロード: 5〜10 並列で同時処理。スタックジョブ向けの監視・アラートを足す。

1 日 10,000 以上: Webhook ベースの完全非同期、専用ジョブトラッキング DB、デッドレターキュー、自動スケールワーカー or スケーリングを引き受けてくれるクラウド API。詳しくは バッチ動画変換ガイド

コスト感

1 日 5,000 アップロード(平均 2 分)の SNS 系プラットフォーム想定:

  • セルフホスト(GPU 専用サーバー): サーバーで月 $2,000〜5,000 + エンジニア工数
  • クラウドトランスコード API: 出力バリエーション数次第で月 $500〜1,500
  • ハイブリッド: ベース負荷を専用ハードウェア、オーバーフローをクラウド API

落とし穴あるある

1. 音声なし/無音動画の扱いを忘れる

音声ストリームがないアップロードがある。存在しない音声をエンコードしようとすると FFmpeg がエラーを吐きます:

# 安全处理:如果没有音轨则跳过音频编码
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -c:a aac -b:a 128k output.mp4 2>&1 || \
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -an output.mp4

2. アスペクト比を無視

固定解像度にスケールするときアスペクト比を保たないと、引き伸ばされた動画になる。空き次元には -2 を、または force_original_aspect_ratio=decrease を必ず使うこと。

3. 処理タイムアウトなし

壊れたファイルで FFmpeg がハングする可能性がある。必ずタイムアウトを設定:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 600000); // 10 分钟超时

try {
  await fetch(url, { signal: controller.signal });
} finally {
  clearTimeout(timeout);
}

4. 1 解像度しか保存しない

オリジナルだけ保存すると、遅い回線で毎回バッファリング。逆にトランスコード版を 1 つしか保存しないと、回線状況に適応できない。最低でも 2〜3 解像度を保持すること。

まとめ

UGC 動画パイプラインは複雑である必要はないけれど、信頼性は必須です。コアレシピ:

  1. 検証を早めに(フォーマット、サイズ、長さ)
  2. 正規化して H.264 MP4 + 一貫した設定に
  3. 生成——サムネとプレビューはトランスコードと並列で
  4. 出力——ABR 配信用に複数解像度
  5. 配信——CDN + 適切なキャッシュヘッダー

このパイプラインコードはこれらをすべて扱います。トランスコード処理は自社サーバーで FFmpeg を回しても、FFHub.io のようなクラウド API でインフラ管理を完全に外してもいい。

シンプルに始めて——1 解像度 + サムネイルから——プラットフォームの成長に合わせて複雑さ(マルチ解像度、アニメプレビュー、HLS パッケージング)を足していくのがおすすめです。SaaS プロダクトなら SaaS の動画処理ガイド の build vs buy と統合パターンも参考になります。

関連記事

UGC プラットフォームの動画処理 — 実装ガイド | FFHub