← All posts

SaaS 加视频功能:自建还是用云 API?附集成代码

SaaS 产品要加视频功能时,自建还是用云 API?带集成模式、成本测算和完整 Node.js 实现。

FFHub·2026-05-11
SaaS 加视频功能:自建还是用云 API?附集成代码

你的 SaaS 本来不打算做视频。然后客户在课程编辑器里要视频上传,项目管理工具要屏幕录制预览,CMS 要在分发前把用户上传的视频压一压。突然你就被拽进了视频处理这摊事——哪怕你根本不想碰。

这篇是给 SaaS 产品和工程团队看的:怎么在不拖累产品节奏的前提下加上视频处理能力。包括什么时候该自建、什么时候用云 API、以及怎么把云视频 API 干净地接进现有架构。

你的 SaaS 什么时候会需要视频处理

视频处理不是你主动要的功能,是它找上门的。最常见的几种触发点:

在线教育(LMS)

讲师拿各种格式上传录课。学生希望任何设备都能秒播。你需要格式转换、压缩、多分辨率分发。本质上和 UGC 平台面对的是同一类问题。

内容管理系统(CMS)

编辑跟着文章一起上原始视频。需要压缩省存储、出缩略图做预览、统一格式保证播放一致。

项目管理 / 协作工具

团队分享屏幕录制、产品演示、会议精华。屏幕录制可以非常大,要压;要预览缩略图;有时还要剪片段做高亮。

客服平台

客户上传视频附件演示 Bug。客服需要能在工单里快速加载的缩略图和压缩版。

电商 / 市场平台

卖家上传商品视频。要统一分辨率、压一压保证页面加载、出缩略图给商品列表。

房产平台

经纪人上传看房视频。要压缩、出缩略图、有时还要剪片段做房源预览。

大家其实都需要的就那几样

把上面几类 SaaS 摆到一起,需求收敛到一个意外短的列表:

需求说明FFmpeg 例子
格式转换输入啥都行,输出标准 MP4ffmpeg -i input.avi -c:v libx264 -c:a aac output.mp4
压缩减小体积,省存储省带宽ffmpeg -i input.mp4 -crf 28 -preset medium output.mp4
缩略图截一帧ffmpeg -i input.mp4 -ss 2 -frames:v 1 thumb.jpg
缩放统一到目标分辨率ffmpeg -i input.mp4 -vf "scale=-2:720" output.mp4
剪片段截某一段ffmpeg -i input.mp4 -ss 30 -t 15 clip.mp4
提音频从视频里取音频ffmpeg -i input.mp4 -vn -c:a aac audio.m4a
水印加品牌覆盖ffmpeg -i input.mp4 -i logo.png -filter_complex "overlay=10:10" output.mp4

多数 SaaS 产品只需要其中 2-3 项。这是「自建 vs 外购」决策里很关键的一点。

自建 vs 外购:决策表

直接来量化的。

什么时候自建(自己跑 FFmpeg)

只有当下面全部满足时再自建:

  • 有专职基础设施 / DevOps 团队
  • 视频处理本身就是产品的差异化能力
  • 月处理 100,000+ 视频(成本敏感了)
  • 数据合规要求严到没有云服务商能满足
  • 能拿出 2-4 周做初始搭建,再加上长期维护

什么时候外购(云 API)

下面任一条满足,就去用云 API:

  • 团队不到 20 个工程师
  • 视频是辅助功能,不是核心产品
  • 要按天上线,不是按周
  • 月处理量 10 万以下
  • 不想自己管 FFmpeg 版本、扩缩容、故障恢复

决策矩阵

维度自建外购
团队规模20+ 工程师,有专职基础设施团队任意
视频量10 万+/月10 万以下/月
上线时间至少 2-4 周1-3 天
视频是核心吗是,差异化能力否,辅助功能
数据合规严,必须内部标准云合规即可
维护负担长期(FFmpeg 升级、扩容、监控)没有
大规模成本边际成本低边际成本高
小规模成本高(服务器 + 工程)低(按量付费)

真实账面

拿一个典型 SaaS 来算——一个 CMS,月处理 5,000 个视频(每个平均 3 分钟,1080p 输出):

自建:

  • 服务器:2 台 c5.2xlarge 或同级($250/月)
  • 工程搭建:80 小时 × $100/h = $8,000(一次性)
  • 持续维护:10 小时/月 × $100/h = $1,000/月
  • 第一年总计:$8,000 + ($250 + $1,000) × 12 = $23,000

云 API:

  • 处理费:约 $300/月(5,000 视频 × 约 $0.06/个)
  • 工程搭建:8 小时 × $100/h = $800(一次性)
  • 持续维护:约 0 小时/月
  • 第一年总计:$800 + $300 × 12 = $4,400

盈亏平衡点大约在月处理 5 万-8 万个视频,看你工程人时单价。低于这个量,云 API 完胜。云 API 怎么挑见我们的 FFHub vs AWS MediaConvert

集成姿势

把视频处理接进 SaaS 架构主要有两种姿势。

姿势一:异步 + webhook(推荐)

视频超过 10 秒、或者要出多种产物,都建议异步。

用户上传视频
        │
        v
┌──────────────┐     ┌──────────────┐
│  Your API    │────>│  Store raw   │
│  Server      │     │  in S3/R2    │
└──────┬───────┘     └──────────────┘
       │
       │ POST /tasks (FFmpeg 命令 + webhook URL)
       v
┌──────────────┐
│  Transcoding │
│  API         │
└──────┬───────┘
       │
       │ ... 处理中 ...
       │
       │ POST webhook 回调
       v
┌──────────────┐     ┌──────────────┐
│  Your API    │────>│  更新 DB     │
│  (webhook)   │     │  通知用户    │
└──────────────┘     └──────────────┘

实现:

// 步骤 1: 用户上传时,提交转码任务
app.post("/api/videos/upload", auth, upload.single("video"), async (req, res) => {
  const rawUrl = await uploadToS3(req.file);

  // 在数据库创建记录
  const video = await db.query(
    `INSERT INTO videos (id, user_id, status, source_url, created_at)
     VALUES ($1, $2, 'processing', $3, NOW()) RETURNING *`,
    [generateId(), req.user.id, rawUrl]
  );

  // 提交转码任务
  await fetch("https://api.ffhub.io/v1/tasks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FFHUB_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      command: `ffmpeg -i ${rawUrl} -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -movflags +faststart output.mp4`,
      webhook_url: `https://your-app.com/webhooks/video-processed?video_id=${video.id}`,
    }),
  });

  // 同时提交缩略图任务
  await fetch("https://api.ffhub.io/v1/tasks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FFHUB_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      command: `ffmpeg -i ${rawUrl} -ss 2 -frames:v 1 -q:v 2 thumbnail.jpg`,
      webhook_url: `https://your-app.com/webhooks/thumbnail-generated?video_id=${video.id}`,
    }),
  });

  res.json({ id: video.id, status: "processing" });
});

// 步骤 2: 接收 webhook 回调
app.post("/webhooks/video-processed", async (req, res) => {
  const { video_id } = req.query;
  const { status, output_url, error } = req.body;

  if (status === "completed") {
    await db.query(
      `UPDATE videos SET status = 'ready', processed_url = $1, processed_at = NOW() WHERE id = $2`,
      [output_url, video_id]
    );
    // 可选:通知用户视频已就绪
    await notifyUser(video_id, "Your video is ready");
  } else {
    await db.query(
      `UPDATE videos SET status = 'failed', error = $1 WHERE id = $2`,
      [error, video_id]
    );
  }

  res.sendStatus(200);
});

姿势二:同步处理(小文件)

非常短的视频(10 秒以内)或缩略图,可以同步:

app.post("/api/videos/quick-process", auth, upload.single("video"), async (req, res) => {
  const rawUrl = await uploadToS3(req.file);

  // 提交任务
  const taskRes = await fetch("https://api.ffhub.io/v1/tasks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FFHUB_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      command: `ffmpeg -i ${rawUrl} -c:v libx264 -crf 23 -t 10 -movflags +faststart output.mp4`,
    }),
  });
  const { task_id } = await taskRes.json();

  // 轮询等待(带超时)
  const result = await pollWithTimeout(task_id, 60000); // 60 秒超时

  res.json({
    id: generateId(),
    status: "ready",
    url: result.output_url,
  });
});

这种姿势别滥用。任何可能超过 30 秒的,都用异步。

安全要点

SaaS 场景下的视频处理有自己的安全坑。

签名 URL

不要把原始存储 URL 直接给前端。用带过期的签名 URL:

// 生成限时签名 URL
function generateSignedUrl(objectKey, expiresInSeconds = 3600) {
  return s3.getSignedUrl("getObject", {
    Bucket: process.env.S3_BUCKET,
    Key: objectKey,
    Expires: expiresInSeconds,
  });
}

// 给前端的 URL 永远带签名
app.get("/api/videos/:id/stream", auth, async (req, res) => {
  const video = await db.query("SELECT * FROM videos WHERE id = $1", [req.params.id]);

  // 鉴权
  if (video.user_id !== req.user.id && !req.user.isAdmin) {
    return res.status(403).json({ error: "Forbidden" });
  }

  const signedUrl = generateSignedUrl(video.storage_key, 7200); // 2 小时有效
  res.json({ url: signedUrl });
});

数据保留策略

定好规则并执行:

// 定期清理已处理的源文件
async function cleanupRawFiles() {
  const staleVideos = await db.query(
    `SELECT * FROM videos
     WHERE status = 'ready'
     AND processed_at < NOW() - INTERVAL '7 days'
     AND source_deleted = false`
  );

  for (const video of staleVideos.rows) {
    await deleteFromS3(video.source_url);
    await db.query(
      "UPDATE videos SET source_deleted = true WHERE id = $1",
      [video.id]
    );
  }
}

输入校验

用户什么文件都敢传。处理前先校验:

async function validateVideoFile(fileUrl) {
  // 用 FFprobe 验证确实是视频文件
  const probe = await runFFprobe(fileUrl);

  // 必须有视频流
  const videoStream = probe.streams.find((s) => s.codec_type === "video");
  if (!videoStream) {
    throw new Error("No video stream found");
  }

  // 时长上限(防滥用)
  if (parseFloat(probe.format.duration) > 3600) {
    throw new Error("Video exceeds 1 hour limit");
  }

  // 分辨率上限
  if (videoStream.width > 7680 || videoStream.height > 4320) {
    throw new Error("Resolution exceeds 8K limit");
  }

  return probe;
}

多租户隔离

一个租户的处理别影响另一个:

  • 每个租户独立存储路径:s3://bucket/tenant-{id}/videos/...
  • webhook URL 里带租户 ID 用于校验
  • 按租户配额

完整实现:SaaS 视频处理模块

下面是一个能直接放进生产的 SaaS 视频模块:

// video-service.js
const API_BASE = "https://api.ffhub.io/v1";

class VideoService {
  constructor(apiKey, webhookBaseUrl) {
    this.apiKey = apiKey;
    this.webhookBase = webhookBaseUrl;
  }

  // 视频处理(格式转换 + 压缩 + 缩略图)
  async processUpload(videoId, sourceUrl, options = {}) {
    const {
      maxResolution = 1080,
      crf = 23,
      generateThumbnail = true,
      generatePreview = false,
    } = options;

    const tasks = [];

    // 主视频转码
    tasks.push(
      this.submitTask(
        `ffmpeg -i ${sourceUrl} -vf "scale='min(${maxResolution * 16/9},iw)':'min(${maxResolution},ih)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2" -c:v libx264 -crf ${crf} -preset medium -c:a aac -b:a 128k -movflags +faststart output.mp4`,
        `${this.webhookBase}/video-ready?id=${videoId}&type=main`
      )
    );

    // 缩略图
    if (generateThumbnail) {
      tasks.push(
        this.submitTask(
          `ffmpeg -i ${sourceUrl} -ss 2 -frames:v 1 -vf "scale=640:-2" -q:v 2 thumbnail.jpg`,
          `${this.webhookBase}/video-ready?id=${videoId}&type=thumbnail`
        )
      );
    }

    // 动画预览
    if (generatePreview) {
      tasks.push(
        this.submitTask(
          `ffmpeg -i ${sourceUrl} -ss 5 -t 4 -vf "fps=10,scale=320:-2" -c:v libwebp -quality 50 -loop 0 preview.webp`,
          `${this.webhookBase}/video-ready?id=${videoId}&type=preview`
        )
      );
    }

    return Promise.all(tasks);
  }

  // 提取视频片段
  async extractClip(sourceUrl, startTime, duration, videoId) {
    return this.submitTask(
      `ffmpeg -i ${sourceUrl} -ss ${startTime} -t ${duration} -c:v libx264 -crf 23 -c:a aac -movflags +faststart clip.mp4`,
      `${this.webhookBase}/video-ready?id=${videoId}&type=clip`
    );
  }

  // 压缩归档(降低存储成本)
  async compressForStorage(sourceUrl, videoId) {
    return this.submitTask(
      `ffmpeg -i ${sourceUrl} -c:v libx264 -crf 28 -preset slow -c:a aac -b:a 96k -movflags +faststart compressed.mp4`,
      `${this.webhookBase}/video-ready?id=${videoId}&type=compressed`
    );
  }

  async submitTask(command, webhookUrl) {
    const res = await fetch(`${API_BASE}/tasks`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ command, webhook_url: webhookUrl }),
    });

    if (!res.ok) {
      throw new Error(`API error: ${res.status}`);
    }

    return res.json();
  }
}

export default VideoService;

在 SaaS 应用里这么用:

import VideoService from "./video-service.js";

const videoService = new VideoService(
  process.env.FFHUB_API_KEY,
  "https://your-saas.com/webhooks"
);

// 用户上传处理
app.post("/api/courses/:courseId/videos", auth, upload.single("video"), async (req, res) => {
  const rawUrl = await uploadToStorage(req.file);
  const videoId = generateId();

  await db.query(
    `INSERT INTO videos (id, course_id, user_id, status, source_url)
     VALUES ($1, $2, $3, 'processing', $4)`,
    [videoId, req.params.courseId, req.user.id, rawUrl]
  );

  await videoService.processUpload(videoId, rawUrl, {
    maxResolution: 1080,
    generateThumbnail: true,
    generatePreview: true,
  });

  res.json({ id: videoId, status: "processing" });
});

各阶段成本预估

不同 SaaS 阶段视频处理大致花多少:

起步期(0-1,000 客户)

  • 视频量:约 500 个/月
  • 平均时长:3 分钟
  • 处理需求:格式转换 + 缩略图
  • 预估月费:$30-50
  • 工程投入:1 天搭建

成长期(1,000-10,000 客户)

  • 视频量:约 5,000 个/月
  • 平均时长:5 分钟
  • 处理需求:转换 + 缩略图 + 压缩
  • 预估月费:$200-400
  • 工程投入:1-2 天接 webhook

规模期(10,000-100,000 客户)

  • 视频量:约 50,000 个/月
  • 平均时长:5 分钟
  • 处理需求:转换 + 多分辨率 + 缩略图 + 预览
  • 预估月费:$1,500-3,000
  • 可以考虑:量大谈折扣、稳态混合处理

企业级(100,000+ 客户)

  • 视频量:50 万+/月
  • 处理需求:完整流水线 + 自定义编码 + 合规
  • 预估月费:$10,000-25,000
  • 可以考虑:自建接稳态 + 云 API 接峰值、专用基础设施

每个阶段都要把 API 费用和「自己搭维护」的工程时间放一起比。一个高级工程师的月薪轻松超过起步期、成长期的 API 费用。

SaaS 特有的几个常见模式

按套餐限配额

把视频处理变成 SaaS 定价的一个维度:

const PLAN_LIMITS = {
  free:       { monthlyVideos: 10,  maxDuration: 60,   maxResolution: 720 },
  starter:    { monthlyVideos: 100, maxDuration: 300,  maxResolution: 1080 },
  business:   { monthlyVideos: 1000, maxDuration: 1800, maxResolution: 1080 },
  enterprise: { monthlyVideos: -1,   maxDuration: 7200, maxResolution: 4320 },
};

async function checkQuota(userId) {
  const user = await getUser(userId);
  const limits = PLAN_LIMITS[user.plan];

  if (limits.monthlyVideos === -1) return true; // 无限

  const thisMonth = await db.query(
    `SELECT COUNT(*) FROM videos
     WHERE user_id = $1 AND created_at >= date_trunc('month', NOW())`,
    [userId]
  );

  return thisMonth.count < limits.monthlyVideos;
}

用数据库做后台队列

API 别被视频处理阻塞。一个简单的数据库队列就够:

// 入队
async function enqueueVideoProcessing(videoId, sourceUrl, options) {
  await db.query(
    `INSERT INTO processing_queue (video_id, source_url, options, status, created_at)
     VALUES ($1, $2, $3, 'pending', NOW())`,
    [videoId, sourceUrl, JSON.stringify(options)]
  );
}

// Worker 进程(每 5 秒拉一次)
async function processQueue() {
  const job = await db.query(
    `UPDATE processing_queue
     SET status = 'processing', started_at = NOW()
     WHERE id = (
       SELECT id FROM processing_queue
       WHERE status = 'pending'
       ORDER BY created_at
       LIMIT 1
       FOR UPDATE SKIP LOCKED
     )
     RETURNING *`
  );

  if (job.rows.length === 0) return;

  const { video_id, source_url, options } = job.rows[0];
  await videoService.processUpload(video_id, source_url, JSON.parse(options));
}

setInterval(processQueue, 5000);

给前端看的状态接口

让前端能查处理进度:

app.get("/api/videos/:id/status", auth, async (req, res) => {
  const video = await db.query(
    "SELECT id, status, thumbnail_url, processed_url FROM videos WHERE id = $1",
    [req.params.id]
  );

  res.json({
    id: video.id,
    status: video.status, // 'processing' | 'ready' | 'failed'
    thumbnail: video.thumbnail_url,
    url: video.status === "ready" ? video.processed_url : null,
  });
});

小结

给 SaaS 加视频处理,不需要再组建一个视频基础设施团队。决策很直接:

  1. 先看实际需要什么。 多数 SaaS 只要格式转换、压缩、缩略图三件事。
  2. 看量级和团队规模选自建/外购。 月 10 万以下、小团队?外购。月 10 万以上、有专职基础设施团队?再考虑自建。
  3. 能异步就异步。 超过几秒的处理都用 webhook。用户不想盯着加载圈圈。
  4. 为增长留余地。 起步用云 API,量上来再考虑混合。

文中的代码示例可以直接当起点。VideoService 类可以丢进任何 Node.js SaaS 项目,按需求慢慢加。

如果走云 API 路线,FFHub.io 提供和本地一样语法的 FFmpeg API,自动扩缩容、不用管基础设施。如果你在考虑 Serverless,先读一下 FFmpeg 在 Lambda 上的挑战,省得再走一遍弯路。

延伸阅读

SaaS 加视频功能:自建还是用云 API?附集成代码 | FFHub