← All posts

Batch-Transcoding via API: Architektur und Skalierung

Wie du eine zuverlässige Pipeline für Batch Video Transcoding per API baust. Queue-Architektur, Fehlerbehandlung, Retry-Strategien und ein vollständiges Node.js-Beispiel.

FFHub·2026-05-11
Batch-Transcoding via API: Architektur und Skalierung

Ein einzelnes Video zu verarbeiten ist trivial. Zehntausend Videos durchzudrücken, ohne nachts wachzuliegen, ist ein ganz anderes Problem. Egal ob du eine Mediathek migrierst, User-Uploads normalisierst oder ein Archiv auf einen neuen Codec umstellst — Batch Transkodierung (transcoding) bringt Probleme mit, die du im kleinen Maßstab nie siehst: Queue-Verwaltung, Concurrency-Limits, Fehler-Recovery, Kostenkontrolle.

Dieser Guide geht durch die Architekturmuster, die Implementierungsdetails und die operativen Strategien, die du brauchst, um eine produktionsreife Batch-Pipeline zu bauen.

Wann du Batch Transcoding wirklich brauchst

Batch Transcoding heißt nicht einfach „FFmpeg in einer Schleife". Es ist eine eigene Workload mit eigenen Anforderungen. Die häufigsten Szenarien:

UGC-Plattform-Backfill

Du bist mit allem live gegangen, was die User hochgeladen haben. Jetzt brauchst du jedes Video als H.264 MP4 in drei Auflösungen für adaptives Streaming. Aus 50.000 Quelldateien werden 150.000 Outputs. Unser Guide zu UGC-Plattformen behandelt diese Pipeline im Detail.

Mediathek-Migration

Umzug von On-Premise-Storage in die Cloud, oder Wechsel des CDN-Providers. Jedes Asset muss neu encodiert werden, damit es zur neuen Delivery-Spec passt.

Format-Standardisierung

Auf der Plattform haben sich Videos in Dutzenden Formaten angesammelt — MKV, AVI, MOV, WMV, FLV. Support-Tickets stapeln sich, weil manche Sachen auf Mobile nicht abspielen. Du brauchst einen Einmal-Job (oder einen wiederkehrenden), der alles normalisiert.

Archiv und Compliance

Regulatorische oder interne Vorgaben verlangen bestimmte Codecs, Auflösungen oder Wasserzeichen auf allen historischen Inhalten.

Warum Batch Transcoding hart ist

ffmpeg in einer For-Loop auf einem einzigen Server funktioniert für 20 Dateien. Für 20.000 nicht. Hier die Gründe:

Resource Contention

Transkodierung ist CPU-intensiv. Ein einzelner 1080p-Encode kann 4-8 Cores blockieren. Wenn du auf einer 8-Core-Maschine 10 parallel laufen lässt, bekommst du Thrashing, OOM-Kills und schlechte Output-Qualität durch Timeouts.

Queue-Verwaltung

Du musst tracken, welche Dateien pending, in-progress, completed oder failed sind. Ohne richtige Queue endest du mit Doppelverarbeitung, verlorenen Jobs oder einem Zustand nach Restart, aus dem du nicht mehr rauskommst.

Fehlerbehandlung im großen Maßstab

Bei 10.000 Dateien sind 1 % Fehlerrate 100 Failures. Kaputte Quelldateien, inkompatible Codecs, Netzwerk-Timeouts, Disk Full — jeder Fehlermodus braucht eigene Behandlung.

Skalierungsdecke

Eine einzelne Maschine hat eine feste Anzahl Cores. Wenn dein Backlog wächst, brauchst du horizontale Skalierung — mehrere Worker, Job-Verteilung und Result-Aggregation.

Architekturmuster

Es gibt drei gängige Ansätze für Batch Transcoding. Jeder macht andere Trade-offs.

Muster 1: Queue + Worker Pool (selbst gehostet)

┌──────────┐    ┌───────────┐    ┌──────────────┐
│  Job DB  │───>│  Queue    │───>│  Worker 1    │
│          │    │ (Redis /  │    │  (FFmpeg)    │
│          │    │  RabbitMQ)│    ├──────────────┤
│          │    │           │───>│  Worker 2    │
│          │    │           │    │  (FFmpeg)    │
│          │    │           │    ├──────────────┤
│          │    │           │───>│  Worker N    │
└──────────┘    └───────────┘    └──────────────┘

Pro: Volle Kontrolle, keine Per-Job-Kosten, läuft offline.

Contra: Du bist für Server, FFmpeg-Versionen, Skalierung, Monitoring und Failure Recovery verantwortlich. Worker-Provisioning kostet Engineering-Zeit. Ein dedizierter DevOps-Aufwand.

Muster 2: Event-Driven (Cloud Functions)

┌──────────┐    ┌───────────┐    ┌──────────────┐
│  S3      │───>│  Lambda / │───>│  S3 Output   │
│  Upload  │    │  Cloud Fn │    │  Bucket      │
│  Event   │    │  (FFmpeg) │    │              │
└──────────┘    └───────────┘    └──────────────┘

Pro: Skaliert automatisch mit der Last, keine Idle-Server, bezahlt pro Aufruf.

Contra: Function-Timeout-Limits (15 min auf AWS Lambda), Cold Starts (Kaltstarts), eingeschränkter Support für FFmpeg-Binaries, Memory-Limits. Okay für Thumbnails, problematisch für lange Videos. Mehr dazu im Detail unter FFmpeg auf Serverless: Probleme und Lösungen.

Muster 3: Cloud Transcoding API

┌──────────┐    ┌───────────┐    ┌──────────────┐
│  Your    │───>│  API      │───>│  Webhook     │
│  App     │    │  Service  │    │  Callback    │
│          │    │           │    │              │
└──────────┘    └───────────┘    └──────────────┘

Pro: Keine Infrastruktur zu betreiben, skaliert auf jedes Volumen, eingebaute Fehlerbehandlung und Retries, konsistente FFmpeg-Version.

Contra: Per-Job-Kosten, braucht Netzwerkverbindung, Daten verlassen deine Infrastruktur.

Vergleich

FaktorSelf-Hosted QueueCloud FunctionsCloud API
Setup-ZeitTage bis WochenStundenMinuten
SkalierungManuellAuto (mit Limits)Auto
Max. VideolängeUnbegrenzt~15 minUnbegrenzt
WartungHochMittelKeine
Kosten bei 100 Videos/Tag$$ (Server)$$
Kosten bei 10.000 Videos/Tag$$$ (Cluster)$$$ (Invocations)$$
FehlerbehandlungSelbst bauenSelbst bauenEingebaut

Praxisbeispiel: Batch Transcoding mit einer Cloud API

Bauen wir ein vollständiges Batch-System mit Node.js. Die Architektur ist simpel: Liste mit Quellvideos einlesen, sie als API-Tasks mit kontrollierter Concurrency abschicken, Fortschritt tracken, Failures handhaben.

Projekt-Setup

mkdir batch-transcode && cd batch-transcode
npm init -y
npm install p-limit

Der Batch-Processor

// batch-transcode.js
import pLimit from "p-limit";

const API_BASE = "https://api.ffhub.io/v1";
const API_KEY = process.env.FFHUB_API_KEY;
const CONCURRENCY = 20; // max parallel jobs
const POLL_INTERVAL = 5000; // 5 seconds
const MAX_RETRIES = 3;

// 模拟视频源列表 — 实际场景从数据库或 S3 列表获取
function getVideoList() {
  return Array.from({ length: 100 }, (_, i) => ({
    id: `video-${String(i + 1).padStart(4, "0")}`,
    url: `https://your-bucket.s3.amazonaws.com/raw/video-${i + 1}.mp4`,
  }));
}

// 提交单个转码任务
async function submitTask(video) {
  const response = await fetch(`${API_BASE}/tasks`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      command: `ffmpeg -i ${video.url} -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -movflags +faststart output.mp4`,
    }),
  });

  if (!response.ok) {
    throw new Error(`Submit failed: ${response.status} ${response.statusText}`);
  }

  const data = await response.json();
  return data.task_id;
}

// 轮询任务状态直到完成或失败
async function waitForCompletion(taskId) {
  while (true) {
    const response = await fetch(`${API_BASE}/tasks/${taskId}`, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    });

    const task = await response.json();

    if (task.status === "completed") {
      return { success: true, output_url: task.output_url, duration: task.duration };
    }

    if (task.status === "failed") {
      throw new Error(`Task failed: ${task.error}`);
    }

    // 仍在处理中,等待后继续轮询
    await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
  }
}

// 带重试的单视频处理
async function processVideo(video, attempt = 1) {
  try {
    console.log(`[${video.id}] 提交任务 (尝试 ${attempt}/${MAX_RETRIES})`);
    const taskId = await submitTask(video);
    console.log(`[${video.id}] 任务已提交: ${taskId}`);

    const result = await waitForCompletion(taskId);
    console.log(`[${video.id}] 完成 — 耗时 ${result.duration}s`);
    return { video, status: "completed", ...result };
  } catch (error) {
    if (attempt < MAX_RETRIES) {
      const delay = Math.pow(2, attempt) * 1000; // 指数退避
      console.warn(`[${video.id}] 失败,${delay / 1000}s 后重试: ${error.message}`);
      await new Promise((resolve) => setTimeout(resolve, delay));
      return processVideo(video, attempt + 1);
    }
    console.error(`[${video.id}] 最终失败: ${error.message}`);
    return { video, status: "failed", error: error.message };
  }
}

// 主函数 — 带并发限制的批量处理
async function main() {
  const videos = getVideoList();
  const limit = pLimit(CONCURRENCY);

  console.log(`开始批量转码: ${videos.length} 个视频, 并发: ${CONCURRENCY}`);
  const startTime = Date.now();

  const results = await Promise.all(
    videos.map((video) => limit(() => processVideo(video)))
  );

  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
  const completed = results.filter((r) => r.status === "completed").length;
  const failed = results.filter((r) => r.status === "failed").length;

  console.log(`\n===== 批量处理完成 =====`);
  console.log(`总计: ${videos.length} | 成功: ${completed} | 失败: ${failed}`);
  console.log(`总耗时: ${elapsed}s`);

  // 输出失败列表以便后续排查
  if (failed > 0) {
    console.log(`\n失败列表:`);
    results
      .filter((r) => r.status === "failed")
      .forEach((r) => console.log(`  ${r.video.id}: ${r.error}`));
  }
}

main();

Ausführen:

FFHUB_API_KEY=your_key node batch-transcode.js

Wichtige Design-Entscheidungen

Warum p-limit statt Promise.all über das ganze Array? 10.000 API-Requests gleichzeitig abzufeuern bringt jeden Endpoint zu Fall. p-limit sorgt dafür, dass höchstens CONCURRENCY Jobs parallel laufen — quasi ein client-seitiger Rate Limiter.

Warum Exponential Backoff? Vorübergehende Fehler (Netzwerk-Aussetzer, Rate Limits) lösen sich oft von selbst. Sofort zu retryen erhöht nur die Last. Exponentielles Zurückgehen — 2s, 4s, 8s — gibt dem System Zeit, sich zu erholen.

Warum hier Polling statt Webhooks? Polling ist für Batch-Skripte einfacher. In Produktivsystemen mit Webserver sind Webhooks effizienter — siehe nächster Abschnitt.

Webhook-basierte Architektur (Production)

In Produktivsystemen ist es Verschwendung, tausende Tasks zu pollen. Nimm stattdessen Webhooks:

// Express webhook endpoint
app.post("/webhooks/transcode-complete", async (req, res) => {
  const { task_id, status, output_url, error } = req.body;

  // 更新数据库中的任务状态
  await db.query(
    `UPDATE transcode_jobs
     SET status = $1, output_url = $2, error = $3, completed_at = NOW()
     WHERE task_id = $4`,
    [status, output_url, error, task_id]
  );

  // 如果失败且未超过重试次数,重新入队
  if (status === "failed") {
    const job = await db.query(
      "SELECT * FROM transcode_jobs WHERE task_id = $1",
      [task_id]
    );
    if (job.retry_count < MAX_RETRIES) {
      await enqueueRetry(job);
    }
  }

  res.sendStatus(200);
});

Beim Abschicken der Tasks die Webhook-URL mitgeben:

const response = await fetch(`${API_BASE}/tasks`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    command: `ffmpeg -i ${video.url} -c:v libx264 -crf 23 output.mp4`,
    webhook_url: "https://your-app.com/webhooks/transcode-complete",
  }),
});

Strategien für die Fehlerbehandlung

Im Batch-Maßstab sind Fehler unvermeidbar. So kategorisierst und behandelst du sie:

FehlertypBeispielStrategie
VorübergehendNetzwerk-Timeout, 503Retry mit Backoff
QuelldateiKaputte Datei, Codec nicht unterstütztLoggen und überspringen, zur Prüfung markieren
KonfigurationUngültige FFmpeg-ParameterCommand fixen, Batch erneut starten
Rate Limit429 Too Many RequestsConcurrency reduzieren, Delay einbauen
PermanentFile not found (404)Loggen, kein Retry

Dead Letter Queue

Nach MAX_RETRIES Failures nicht einfach loggen und vergessen. Schieb gescheiterte Jobs in eine Dead Letter Queue zur manuellen Prüfung:

async function handlePermanentFailure(video, error) {
  await db.query(
    `INSERT INTO dead_letter_queue (video_id, source_url, error, failed_at)
     VALUES ($1, $2, $3, NOW())`,
    [video.id, video.url, error]
  );
}

Fortschritts-Tracking

Bei großen Batches brauchst du Sichtbarkeit. Hier ein minimaler Progress-Tracker:

class BatchProgress {
  constructor(total) {
    this.total = total;
    this.completed = 0;
    this.failed = 0;
    this.startTime = Date.now();
  }

  update(status) {
    if (status === "completed") this.completed++;
    if (status === "failed") this.failed++;

    const done = this.completed + this.failed;
    const percent = ((done / this.total) * 100).toFixed(1);
    const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(0);
    const rate = (done / (elapsed || 1)).toFixed(1);
    const eta = ((this.total - done) / (rate || 1)).toFixed(0);

    process.stdout.write(
      `\r[${percent}%] ${done}/${this.total} | ` +
      `OK: ${this.completed} FAIL: ${this.failed} | ` +
      `${rate}/s | ETA: ${eta}s   `
    );
  }
}

Kostenanalyse im Maßstab

Hier ein realistischer Kostenvergleich für einen Einmal-Job über 10.000 Videos (Durchschnitt 5 Minuten, 1080p H.264 Output):

AnsatzCompute-KostenEngineering-ZeitGesamtdauerGesamtkosten
Einzelner Server (16-core)~$200/Mo2-3 Tage Setup~7 Tage$400+
Kubernetes Cluster (40 cores)~$500/Mo1-2 Wochen Setup~2 Tage$1,500+
AWS Elastic Transcoder~$1501-2 Tage~4 Stunden$300+
Cloud FFmpeg API~$1002-3 Stunden~2 Stunden$150+

Die selbst gehosteten Optionen sehen beim reinen Compute günstiger aus, aber die Engineering-Zeit dominiert. Ein Backend-Engineer kostet $80-150/Stunde. Zwei Tage Setup und Debugging übersteigen schon locker die API-Kosten für 10.000 Videos.

Bei wiederkehrenden Workloads kippt die Mathematik. Wenn du 100.000 Videos pro Monat verarbeitest, amortisiert ein selbst gehosteter Cluster die Setup-Kosten. Darunter gewinnt fast immer der API-Ansatz auf die Gesamtkosten gerechnet. Eine detaillierte Build-vs-Buy-Analyse für SaaS-Teams findest du in unserem SaaS-Guide.

Optimierungs-Tipps

1. Nach Dateigröße sortieren

Erst die kleinen Files. So bekommst du schnelle Erfolge und merkst früh, ob dein FFmpeg-Command stimmt — bevor du dich an die großen Encodes bindest.

videos.sort((a, b) => a.fileSize - b.fileSize);

2. Concurrency tunen

Starte mit 10-20 parallelen Jobs und beobachte die API-Antwortzeiten. Wenn Latenz steigt oder Rate Limits greifen, runter mit der Concurrency. Wenn alles stabil bleibt, langsam hochdrehen.

3. Passende Presets nutzen

Bei Batches, wo Geschwindigkeit wichtiger ist als Dateigröße, nimm -preset fast oder -preset veryfast. Der Größenunterschied zwischen medium und fast liegt typischerweise bei 5-10 %, aber die Encoding-Zeit halbiert sich.

4. Bereits verarbeitete Files überspringen

Führ ein Log oder eine Datenbank-Tabelle für verarbeitete Dateien. Vor jedem Job prüfen, damit du nicht für Doppelarbeit bezahlst:

async function shouldProcess(video) {
  const existing = await db.query(
    "SELECT 1 FROM completed_jobs WHERE video_id = $1",
    [video.id]
  );
  return existing.rows.length === 0;
}

Fazit

Batch Video Transcoding ist ein gelöstes Problem — wenn du die richtige Architektur wählst. Für die meisten Teams läuft die Entscheidung auf Volumen und Frequenz hinaus:

  • Einmal-Batch unter 10.000 Videos: Cloud Transcoding API. Der Code aus diesem Artikel funktioniert direkt.
  • Wiederkehrender Batch unter 100.000/Monat: API-basiert mit Webhook-Integration und Datenbank fürs Tracking.
  • Wiederkehrender Batch über 100.000/Monat: Hybrid in Erwägung ziehen — selbst gehostete Worker für die Grundlast, API als Overflow für Peaks.

Die Node.js-Implementierung deckt den häufigen Fall gut ab. Tausch die Quelle aus, pass den FFmpeg-Command an, setz deine Concurrency, lass es laufen.

Wenn du die Infrastruktur komplett überspringen willst: FFHub.io bietet eine Cloud-FFmpeg-API, die Skalierung, Retries und FFmpeg-Versionsverwaltung übernimmt — du schickst Commands und bekommst Ergebnisse.

Verwandte Artikel

Batch-Transcoding via API: Architektur und Skalierung | FFHub