← All posts

SaaS の動画処理——build vs buy と統合の話

SaaS に動画機能が必要になった。自前で組むか買うか?統合パターン、コスト試算、実装サンプルを実例ベースで書きます。

FFHub·2026-05-11
SaaS の動画処理——build vs buy と統合の話

うちの SaaS は動画プラットフォームのつもりで作ってない。でも、ある日コース作成ツールに動画アップロード機能を要望される。プロマネツールに画面録画プレビューが必要になる。CMS でユーザー投稿動画を配信前に圧縮することになる。気付くと動画処理ビジネスに片足突っ込んでいて、できれば抜けたい——という状況です。

この記事では、SaaS のプロダクト・エンジニアリングチームがロードマップを犠牲にせず動画処理を追加する方法を扱います。いつ作り、いつ買い、既存アーキテクチャにクラウド動画 API をきれいに統合する方法を見ていきます。

SaaS で動画処理が必要になるとき

動画処理機能は自分から探しに行くものではなく、向こうからやってきます。よくあるトリガー:

LMS(学習管理システム)

講師は思いつく限りのフォーマットで講義動画をアップロード。受講者はどのデバイスでも即時再生を期待する。フォーマット変換、圧縮、マルチ解像度配信が必要。本質的には UGC プラットフォーム と同じ課題です。

CMS(コンテンツ管理)

編集者は記事と一緒に生動画をアップロード。ストレージコスト削減のための圧縮、プレビュー用サムネイル、再生互換性のためのフォーマット標準化が必要。

プロマネ/コラボレーションツール

チームが画面録画、デモ動画、ミーティングのハイライトを共有。圧縮(画面録画は巨大になりがち)、サムネイル、ハイライト用クリップ抽出が必要。

カスタマーサポート

顧客がバグ再現の動画添付を送ってくる。担当者はチケット画面で素早く読み込めるサムネイルと圧縮版が欲しい。

EC/マーケットプレイス

セラーが商品動画をアップロード。解像度の標準化、ページ速度のための圧縮、商品リスト用サムネイルが必要。

不動産/物件プラットフォーム

エージェントが内見動画をアップロード。圧縮、サムネイル、リスト用プレビュークリップが必要。

よくある動画処理ニーズ

これらの 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 個しか要らない。これは build vs buy の判断で重要なポイントです。

Build vs Buy:判断マトリクス

ここが本題。具体的にいきます。

Build(セルフホスト FFmpeg)するとき

全部 yes なら自前で作る:

  • 専任のインフラ/DevOps チームがある
  • 動画処理がコアプロダクトの差別化要素
  • 月 10 万本以上処理する(コスト感度)
  • 厳密なデータレジデンシー要件があってクラウドでは満たせない
  • 初期セットアップに 2〜4 週、その後の継続メンテにも工数を割ける

Buy(クラウド API)するとき

どれか 1 つでも yes ならクラウド API:

  • チームが 20 人未満
  • 動画はサポート機能であってコアではない
  • 数日でリリースしたい(数週間ではなく)
  • 月 10 万本未満
  • FFmpeg のバージョン管理、スケーリング、障害復旧をやりたくない

判断マトリクス

観点BuildBuy
チーム規模20 人以上、専任インフラ任意
動画ボリューム月 10 万本以上月 10 万本未満
リリース時間最短 2〜4 週1〜3 日
動画はコア機能かはい、差別化要素いいえ、補助機能
データレジデンシー厳格、社内のみ標準的なクラウド準拠でOK
メンテ負担継続的(FFmpeg 更新、スケール、監視)なし
大規模時のコスト限界費用が低い限界費用が高い
小規模時のコスト高い(サーバー + エンジニア)低い(従量課金)

正直なコスト計算

CMS で月 5,000 本(平均 3 分、1080p 出力)処理する想定で比較:

セルフホスト:

  • サーバー:c5.2xlarge × 2(約 $250/月)か同等
  • 初期エンジニアリング:80 時間 × $100/時 = $8,000(一回)
  • 継続メンテ:10 時間/月 × $100/時 = $1,000/月
  • 初年度合計:$8,000 +($250 + $1,000)× 12 = $23,000

クラウド API:

  • 処理コスト:約 $300/月(5,000 本 × 約 $0.06)
  • 初期エンジニアリング:8 時間 × $100/時 = $800(一回)
  • 継続メンテ:約 0
  • 初年度合計:$800 + $300 × 12 = $4,400

損益分岐点はおおむね月 5〜8 万本のあたり(エンジニアコスト次第)。それ以下ではクラウド API が圧勝。クラウド API の比較は FFHub vs AWS MediaConvert を参照。

統合パターン

SaaS アーキテクチャに動画処理を統合する方法は、大きく分けて 2 つ。

パターン 1:非同期 + Webhook(推奨)

10 秒以上の動画や複数出力が必要なときはこちら。

User uploads video
        │
        v
┌──────────────┐     ┌──────────────┐
│  Your API    │────>│  Store raw   │
│  Server      │     │  in S3/R2    │
└──────┬───────┘     └──────────────┘
       │
       │ POST /tasks (FFmpeg command + webhook URL)
       v
┌──────────────┐
│  Transcoding │
│  API         │
└──────┬───────┘
       │
       │ ... processing ...
       │
       │ POST webhook callback
       v
┌──────────────┐     ┌──────────────┐
│  Your API    │────>│  Update DB   │
│  (webhook)   │     │  Notify user │
└──────────────┘     └──────────────┘

実装:

// 步骤 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);
});

パターン 2:小ファイルなら同期

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 動画モジュール

完全な、すぐ使える動画処理モジュール:

// 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
  • エンジニア工数:Webhook 統合に 1〜2 日

スケール(顧客 1〜10 万)

  • 動画ボリューム:月 5 万本
  • 平均長:5 分
  • 必要な処理:変換 + マルチ解像度 + サムネイル + プレビュー
  • 月額試算:$1,500〜3,000
  • 検討事項:ボリュームディスカウント、ベース負荷のハイブリッド処理

エンタープライズ(顧客 10 万以上)

  • 動画ボリューム:月 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 を動画処理でブロックしない。シンプルな DB ベースのキューで十分:

// 入队
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);

フロントエンド向けステータス API

フロントが処理状況を表示できるようにする:

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. ボリュームとチーム規模で build vs buy を決める。 月 10 万本未満で小規模チーム?買え。それを超えていて専任インフラがある?build を検討。
  3. 非同期ファースト。 数秒以上かかるものはすべて Webhook で。スピナーを眺める時間をユーザーに与えない。
  4. 成長を見越して計画する。 クラウド API でスタートし、ボリュームが正当化したらハイブリッドへ。

この記事のサンプルコードはそのまま動く起点として使えます。VideoService クラスは Node.js の SaaS にドロップインできて、必要に応じて拡張可能。

クラウド API 路線なら FFHub.io が素直な FFmpeg API を提供します——ローカルで叩くのと同じコマンドをクラウドで実行、自動スケール、インフラ管理ゼロ。Serverless 路線を考えているなら、Lambda で FFmpeg を動かすときの罠 を先に読むことをおすすめします。

関連記事

SaaS の動画処理——build vs buy と統合の話 | FFHub