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 MP4 | H.265 | VP9/WebM | AV1 |
|---|---|---|---|---|
| ブラウザサポート | 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 | 動画ビットレート(典型) | 音声ビットレート | 想定回線 |
|---|---|---|---|---|
| 1080p | 23 | 3〜5 Mbps | 128k | WiFi / ブロードバンド |
| 720p | 23 | 1.5〜3 Mbps | 96k | 良いモバイル |
| 480p | 26 | 0.5〜1.5 Mbps | 64k | 遅いモバイル |
| 360p | 28 | 0.3〜0.7 Mbps | 48k | 非常に遅い回線 |
完全な処理パイプライン
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) │
└──────────────┘ └──────────────────┘
フロー
- クライアントが API サーバー(または Presigned URL でオブジェクトストレージ直接)に生動画をアップロード
- API サーバーが生ファイルを保存し、処理ジョブを作成
- トランスコード API がソース URL 入りの FFmpeg コマンドを受け取り、処理
- Webhook が処理完了を通知、出力 URL を渡す
- 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 動画パイプラインは複雑である必要はないけれど、信頼性は必須です。コアレシピ:
- 検証を早めに(フォーマット、サイズ、長さ)
- 正規化して H.264 MP4 + 一貫した設定に
- 生成——サムネとプレビューはトランスコードと並列で
- 出力——ABR 配信用に複数解像度
- 配信——CDN + 適切なキャッシュヘッダー
このパイプラインコードはこれらをすべて扱います。トランスコード処理は自社サーバーで FFmpeg を回しても、FFHub.io のようなクラウド API でインフラ管理を完全に外してもいい。
シンプルに始めて——1 解像度 + サムネイルから——プラットフォームの成長に合わせて複雑さ(マルチ解像度、アニメプレビュー、HLS パッケージング)を足していくのがおすすめです。SaaS プロダクトなら SaaS の動画処理ガイド の build vs buy と統合パターンも参考になります。
関連記事
- API での動画バッチトランスコード - 大量動画を確実に処理するアーキテクチャと実装ガイド
- SaaS の動画処理 - 動画が補助機能のときの build vs buy フレームワーク
- FFmpeg で動画フォーマットを変換する方法 - UGC パイプラインで使うフォーマット変換コマンドの入門