← All posts

UGC 平台视频处理:从上传到分发的完整工程指南

用户上传啥格式都有,怎么搭一条能扛规模的视频处理流水线:校验、归一化、缩略图、多分辨率、架构。

FFHub·2026-05-11
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 MP4H.265VP9/WebMAV1
浏览器支持99%+~80%~90%~70%
移动端支持全平台仅 iOS主要 Android有限
编码速度慢 2-3 倍慢 3-5 倍慢 10 倍
硬件解码全平台较新设备Chrome/Android最新设备
文件大小(基准)1x0.5x0.6x0.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典型码率音频码率适用场景
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 服务器(或者通过预签名 URL 直传对象存储)
  2. 你的 API 把原始文件落盘并创建处理任务
  3. 转码 API 收到带源 URL 的 FFmpeg 命令,开始处理
  4. Webhook 完成时回调你的 API,附输出 URL
  5. 你的 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 视频处理流水线不需要多花哨,但必须靠谱。核心套路:

  1. 校验上传内容(格式、大小、时长)
  2. 归一化为 H.264 MP4,参数固定
  3. 并行生成缩略图和预览
  4. 多分辨率输出做自适应分发
  5. CDN 分发,缓存头别忘

文中代码覆盖了以上全部步骤。转码计算可以自己跑服务器,也可以丢给 FFHub.io 这种云 API,省得维护 FFmpeg 基础设施。

建议从简单开始——单分辨率 + 缩略图——平台长起来再加多分辨率、动图预览、HLS 打包。如果你的平台是 SaaS 产品,SaaS 视频处理指南讲了「自建 vs 外购」决策和集成模式。

延伸阅读

UGC 平台视频处理:从上传到分发的完整工程指南 | FFHub