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

你的 SaaS 本来不打算做视频。然后客户在课程编辑器里要视频上传,项目管理工具要屏幕录制预览,CMS 要在分发前把用户上传的视频压一压。突然你就被拽进了视频处理这摊事——哪怕你根本不想碰。
这篇是给 SaaS 产品和工程团队看的:怎么在不拖累产品节奏的前提下加上视频处理能力。包括什么时候该自建、什么时候用云 API、以及怎么把云视频 API 干净地接进现有架构。
你的 SaaS 什么时候会需要视频处理
视频处理不是你主动要的功能,是它找上门的。最常见的几种触发点:
在线教育(LMS)
讲师拿各种格式上传录课。学生希望任何设备都能秒播。你需要格式转换、压缩、多分辨率分发。本质上和 UGC 平台面对的是同一类问题。
内容管理系统(CMS)
编辑跟着文章一起上原始视频。需要压缩省存储、出缩略图做预览、统一格式保证播放一致。
项目管理 / 协作工具
团队分享屏幕录制、产品演示、会议精华。屏幕录制可以非常大,要压;要预览缩略图;有时还要剪片段做高亮。
客服平台
客户上传视频附件演示 Bug。客服需要能在工单里快速加载的缩略图和压缩版。
电商 / 市场平台
卖家上传商品视频。要统一分辨率、压一压保证页面加载、出缩略图给商品列表。
房产平台
经纪人上传看房视频。要压缩、出缩略图、有时还要剪片段做房源预览。
大家其实都需要的就那几样
把上面几类 SaaS 摆到一起,需求收敛到一个意外短的列表:
| 需求 | 说明 | FFmpeg 例子 |
|---|---|---|
| 格式转换 | 输入啥都行,输出标准 MP4 | ffmpeg -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 加视频处理,不需要再组建一个视频基础设施团队。决策很直接:
- 先看实际需要什么。 多数 SaaS 只要格式转换、压缩、缩略图三件事。
- 看量级和团队规模选自建/外购。 月 10 万以下、小团队?外购。月 10 万以上、有专职基础设施团队?再考虑自建。
- 能异步就异步。 超过几秒的处理都用 webhook。用户不想盯着加载圈圈。
- 为增长留余地。 起步用云 API,量上来再考虑混合。
文中的代码示例可以直接当起点。VideoService 类可以丢进任何 Node.js SaaS 项目,按需求慢慢加。
如果走云 API 路线,FFHub.io 提供和本地一样语法的 FFmpeg API,自动扩缩容、不用管基础设施。如果你在考虑 Serverless,先读一下 FFmpeg 在 Lambda 上的挑战,省得再走一遍弯路。
延伸阅读
- 批量视频转码 API - 大批量处理的架构、并发控制和错误处理
- UGC 平台视频处理 - 用户上传视频从入到出的完整流水线
- 什么是 FFHub? - 本文代码用到的云 FFmpeg API 介绍