UGC 平台视频处理:从上传到分发的完整工程指南
用户上传啥格式都有,怎么搭一条能扛规模的视频处理流水线:校验、归一化、缩略图、多分辨率、架构。

用户什么都敢上传。单反拍的 4K ProRes、竖屏录的 WebM、十几年前翻盖手机的 AVI、只有 Safari 能播的 HEVC MOV。平台得能把所有这些收下,再保证不管什么设备、什么网络都有一致的播放体验。
这篇覆盖 UGC 平台从上传到分发的完整流水线——附实战 FFmpeg 命令和能扛规模的架构。
UGC 视频处理流水线长这样
任何接受用户上传视频的平台,最后都会变成这条链路:
┌─────────┐ ┌──────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐
│ Upload │──>│ Validate │──>│ Normalize │──>│ Generate │──>│ Deliver │
│ │ │ & Store │ │ Format │ │ Variants │ │ via CDN │
└─────────┘ └──────────┘ └────────────┘ └────────────┘ └──────────┘
│ │
┌─────┴─────┐ ┌────┴─────┐
│ Transcode │ │ Thumbnails│
│ to H.264 │ │ Previews │
│ MP4 │ │ Multi-res │
└───────────┘ └──────────┘
每个阶段都有自己的技术要求,挨个讲。
阶段一:上传与校验
转码很贵,先在前面把不能用的挡掉。
文件级检查
// 上传校验中间件
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();
}
探测一下文件
收到上传后,用 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);
}
阶段二:格式归一化
目标:进来什么格式都行,出去都长一样。对绝大多数 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 | 最新设备 |
| 文件大小(基准) | 1x | 0.5x | 0.6x | 0.4x |
兼容性上 H.264 没对手。如果用户群在全球、还包含老设备,它是唯一安全的默认。把 H.265 或 AV1 当渐进增强,给支持的客户端走。压缩参数细节见 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 要求)。
阶段三:缩略图与预览
每个视频至少要一张缩略图。多数平台还要做悬停时的动图预览。
静态缩略图
第 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 或 WebP)
3-5 秒循环,鼠标悬停时播:
# 从第 5 秒截 4 秒,10fps、320 宽 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
阶段四:多分辨率输出
要做自适应播放,从源文件出多档分辨率:
# 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 服务器(或者通过预签名 URL 直传对象存储)
- 你的 API 把原始文件落盘并创建处理任务
- 转码 API 收到带源 URL 的 FFmpeg 命令,开始处理
- Webhook 完成时回调你的 API,附输出 URL
- 你的 API 更新数据库,视频通过 CDN 可播
数据库结构
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 分钟 | 5min | ~90s | <1s | ~120s |
| 4K 10 分钟 | 10min | ~300s | <1s | ~360s |
| 720p 1 分钟 | 1min | ~20s | <1s | ~30s |
数字是估算——实际看源文件编码、码率、复杂度。
扩展策略
100 上传/天: 单线程处理 + 简单任务队列。一张数据库表当队列就够。
1,000 上传/天: 5-10 并发。加监控和告警,发现卡住的任务。
10,000+ 上传/天: 完整异步架构:webhook 回调、独立任务跟踪库、死信队列、自动扩缩容 worker,或者直接用云 API 让别人处理扩缩。架构细节见我们的批量视频转码指南。
成本
社交平台每天 5,000 个视频上传(每个平均 2 分钟)大致这样:
- 自建(专用 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. 只存一档分辨率
只存原始?慢网络看就一直缓冲。只存一档转码?没法做自适应。至少存 2-3 档。
小结
UGC 视频处理流水线不需要多花哨,但必须靠谱。核心套路:
- 校验上传内容(格式、大小、时长)
- 归一化为 H.264 MP4,参数固定
- 并行生成缩略图和预览
- 多分辨率输出做自适应分发
- 走 CDN 分发,缓存头别忘
文中代码覆盖了以上全部步骤。转码计算可以自己跑服务器,也可以丢给 FFHub.io 这种云 API,省得维护 FFmpeg 基础设施。
建议从简单开始——单分辨率 + 缩略图——平台长起来再加多分辨率、动图预览、HLS 打包。如果你的平台是 SaaS 产品,SaaS 视频处理指南讲了「自建 vs 外购」决策和集成模式。
延伸阅读
- 批量视频转码 API - 上千视频可靠处理的架构与实现
- SaaS 产品的视频处理 - 视频作为辅助功能时的「自建 vs 外购」决策
- 如何用 FFmpeg 转换视频格式 - UGC 流水线常用格式转换命令入门