CHRISTIAN OHLE
Zurück zu Bauen

Bauen

Assembly-Agent: ffmpeg-Magie hinter dem fertigen Video

Build-in-Public Teil 7: Wie der Assembly-Agent mit ffmpeg per-Szenen-Merge, FPS-Normalisierung und Sidechain-Ducking-BGM zu einem Master-MP4 zusammenfügt.

7 Min Lesezeit assembly agent · ffmpeg sidechain ducking · video pipeline ffmpeg · concat demuxer · fps normalization · multi-agent pipeline
Hero-Image: Assembly-Agent: ffmpeg-Magie hinter dem fertigen Video

TL;DR — Was du nach diesem Artikel weißt

  • Wie der Assembly-Agent alle vorigen Agent-Outputs zu einem Master-MP4 verbindet.
  • Den Per-Szenen-Merge-Code mit -stream_loop -1 für Visual-Looping bei kürzeren Visuals.
  • Den ffmpeg-Concat-Demuxer und warum Format-Konsistenz vorausgesetzt ist.
  • Wie Sidechain-Ducking als simple Volume-Reduktion fast identisch klingt.
  • Warum FPS-Normalisierung der unterschätzte Bug-Killer der Video-Pipeline ist.

Im vorherigen Teil habe ich Voice- und Subtitle-Agent gezeigt — die letzten beiden Agents, die individuelle Daten produzieren. Heute der Assembly-Agent: der finale Konsolidator, der alle vorherigen Outputs einsammelt und zu einem fertigen 6-Min-MP4 zusammenfügt. Per-Szenen-Merge, Concat-Demuxer, BGM-Mix mit Sidechain-Ducking — alles klassisches ffmpeg-Handwerk, aber präzise konfiguriert.

Was diesen Agent besonders macht: er ruft kein LLM auf. Er ist rein deterministisch, baut auf Stream-Format-Konventionen, und ist trotzdem essenziell. Multi-Agent-Pipelines brauchen nicht jede Stage als LLM-Call — manche Stages sind einfach gut konfigurierte Tools, die Inputs zuverlässig in Outputs verwandeln.

“Der Assembly-Agent war der Agent, an dem ich am meisten ffmpeg-Lessons gelernt habe. Drei Tage Bugs für unsynchronisiertes Audio, doppelt so lange Videos, schwarze Frames in der Mitte. Heute läuft er stabil — aber jede einzelne Zeile des ffmpeg-Codes hat eine Bug-Geschichte hinter sich.”

Was der Assembly-Agent konkret tut

Eingang: für jede der 14–20 Szenen ein Visual-MP4 (vom Visual-Orchestrator, ggf. mit B-Roll-Overlay) plus eine Voiceover-MP3 (vom Voice-Agent). Ausgang: data/output/<topic_id>.mp4 — das fertige Video.

Im Assembly-Code:

"""Assembly: nimmt Visual-Clips + Voiceover-Chunks + BG-Music → finales MP4.

Workflow:
1. Pro Szene: Visual-Clip + Voiceover-MP3 → ein Clip mit eingebettetem Audio
2. Alle Szenen-Clips concat → master.mp4
3. BG-Music drüberlegen mit Sidechain-Ducking
4. Auto-Subtitles via Whisper
5. Burn-In via ffmpeg
"""

Vier Sub-Stages, jede mit eigenem ffmpeg-Aufruf. Lass mich sie nacheinander durchgehen.

Sub-Stage 1: Per-Szenen-Merge

Erste Aufgabe: jede Szene wird zu einem Clip mit eingebettetem Audio. Ein Visual-MP4 + ein Voice-MP3 → ein gemerged-MP4.

def merge_scene(visual: Path, audio: Path, output: Path) -> Path:
    """Visual-Clip + Voiceover → ein MP4. Visual wird ggf. auf Audio-Länge gestreckt/geloopt."""
    audio_dur = _probe_duration(audio)
    cmd = [
        "ffmpeg", "-y",
        "-stream_loop", "-1", "-i", str(visual),  # Loop falls Visual kürzer
        "-i", str(audio),
        "-t", f"{audio_dur:.3f}",
        "-c:v", "libx264", "-preset", "veryfast",
        "-r", "30",
        "-pix_fmt", "yuv420p",
        "-vf", "fps=30",
        "-c:a", "aac", "-b:a", "192k", "-ar", "48000",
        "-map", "0:v:0", "-map", "1:a:0",
        "-shortest",
        str(output),
    ]
    _run(cmd)
    return output

Das Schlüssel-Detail hier: -stream_loop -1 auf dem Visual-Input. Wenn das Visual kürzer ist als das Audio (passiert bei Stock-Clips oder Manim-Animationen mit fester Länge), wird das Visual stumm geloopt, bis das Audio durch ist. -shortest auf dem Output-Side stellt sicher, dass am Ende dort gestoppt wird, wo das Audio endet — keine endlosen Visual-Loops nach Ende der Narration.

Zwei weitere harte Regeln:

  • -r 30 und -vf fps=30: alle Clips werden auf 30 FPS normalisiert. Das ist die Voraussetzung für den späteren Concat-Demuxer.
  • -pix_fmt yuv420p: standardisiertes Pixel-Format, kompatibel mit allen Browsern und YouTube-Encoder.
  • -ar 48000: standardisierte Audio-Sample-Rate.

“FPS-Normalisierung kostete mich am ersten Tag drei Stunden Bug-Suche. Manim rendert mit 60 FPS, ComfyUI mit 30, Slide mit 25. Der Concat-Demuxer crashed mit kryptischer Fehlermeldung ‘concat: Different streams’. Lösung: alles auf 30 FPS via -vf fps=30 umrechnen, dann passt der Concat. Heute eine Zeile im Code, damals drei Stunden Frust.”

Sub-Stage 2: Concat-Demuxer

Sind alle Szenen-Clips gemergt, kommt der Concat. Hier wird der Concat-Demuxer genutzt — nicht der einfacher zu nutzende concat-Filter — weil er format-agnostisch ist und keine Re-Encodierung braucht (wenn die Stream-Formate konsistent sind).

def concat_clips(clips: list[Path], output: Path) -> Path:
    """ffmpeg concat-demuxer (sicher, keine Re-Encodierung wenn Format passt)."""
    list_file = output.with_suffix(".txt")
    list_file.write_text(
        "\n".join(f"file '{c.absolute()}'" for c in clips),
        encoding="utf-8",
    )
    cmd = [
        "ffmpeg", "-y", "-f", "concat", "-safe", "0",
        "-i", str(list_file),
        "-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
        "-c:a", "aac", "-b:a", "192k",
        str(output),
    ]
    _run(cmd)
    list_file.unlink(missing_ok=True)
    return output

Wie der Concat-Demuxer arbeitet:

  1. Eine Text-Datei wird geschrieben, mit einer Zeile pro Clip: file '/path/to/clip_001.mp4'
  2. ffmpeg liest diese Datei mit -f concat -i list.txt
  3. Alle Clips werden in der Reihenfolge der Liste verkettet

Das ist deutlich robuster als der concat-Filter, weil:

  • Der Demuxer toleriert leichte Format-Schwankungen (mit -safe 0)
  • Er muss Streams nur dann re-encoden, wenn die Specs nicht passen
  • Er kann auch ohne Re-Encoding laufen mit -c copy — bei meinem Setup re-encode ich aber bewusst, weil meine Quell-Clips manchmal Bitrate-Outliers haben

-safe 0 ist der Trick, der den Concat-Demuxer mit absoluten Pfaden funktionieren lässt. Ohne ihn lehnt ffmpeg jeden Pfad ab, der nicht relativ zur Listen-Datei ist. Drei Stunden Doku-Wühlen, bis ich das gefunden hatte.”

Sub-Stage 3: BGM-Mix mit Sidechain-Ducking

Jetzt kommt der finale Schritt: Background-Music unter den Voiceover legen. Echte Sidechain-Compression wäre die richtige Lösung — die BGM wird automatisch leiser, sobald die Voice-Spur Pegel hat. ffmpeg unterstützt das mit sidechaincompress-Filter, aber:

  1. Es ist langsamer (zwei Audio-Streams analysieren statt einer)
  2. Die Parameter (threshold, ratio, attack, release) brauchen sorgfältiges Tuning
  3. Der Effekt im YouTube-Kontext ist marginal hörbar

Stattdessen: Konstantes Volume-Reduktion der BGM um -22 dB. Klingt im Kontext fast identisch wie echtes Ducking.

def mix_bgm(video: Path, output: Path, bgm_path: Path | None = None,
            bgm_db: int = -22) -> Path:
    """Mischt BG-Music dezent unter den Voiceover.
    Sidechain-Compression simuliert via Volume-Reduktion."""

    cmd = [
        "ffmpeg", "-y",
        "-i", str(video),
        "-stream_loop", "-1", "-i", str(bgm_path),
        "-filter_complex",
        f"[1:a]volume={bgm_db}dB[bgm];"
        f"[0:a][bgm]amix=inputs=2:duration=first:dropout_transition=2[a]",
        "-map", "0:v", "-map", "[a]",
        "-c:v", "copy",
        "-c:a", "aac", "-b:a", "192k",
        str(output),
    ]
    _run(cmd)
    return output

Das Filter-Komplex erklärt:

  • [1:a]volume=-22dB[bgm]: BGM (Stream 1) wird um 22 dB leiser
  • [0:a][bgm]amix=inputs=2:duration=first:dropout_transition=2[a]: Voice (Stream 0) und gedämpfte BGM werden gemischt, Output endet wenn Voice endet (duration=first)
  • -c:v copy: Video-Stream wird nicht re-encodet — spart ~30 Sekunden Render-Zeit

“-22 dB ist der Sweet-Spot. -18 dB ist zu laut, BGM überdeckt Voiceover bei leiseren Passagen. -25 dB ist zu leise, BGM wird unhörbar in Headphones. -22 dB hat YouTube-Standard, klingt ‘professionell’ ohne aufdringlich zu sein.”

Sub-Stage 4: Whisper-Transkription mit Re-Segmentierung

Sobald das Video mit BGM fertig ist, läuft Whisper über die Audio-Spur und erzeugt Untertitel. Das passiert am Master-MP4, nicht an den einzelnen Voice-Files — Whisper performt besser auf längeren zusammenhängenden Audio-Strecken.

def _generate_srt_only(video):
    """Whisper-Transkription, schreibt nur SRT (kein Burn-In)."""
    import whisper
    console.print("[cyan]Whisper-Transkription (nur SRT)...[/cyan]")
    model = whisper.load_model("small")
    result = model.transcribe(str(video), language="de",
                               task="transcribe", word_timestamps=True)
    result["segments"] = _resegment_for_subtitles(
        result.get("segments", []), max_words=7, max_duration=3.5)
    srt_file = Path(video).with_suffix(".srt")
    _write_srt(result["segments"], srt_file)
    return srt_file

Beachte: Whisper “small” statt “large-v3” — bewusste Trade-off-Entscheidung. Für reine deutsche Untertitel-Generierung ist die Genauigkeit ausreichend, die Laufzeit halbiert sich. Bei englischen Skripten würde ich auf “medium” oder “large” hochskalieren.

Re-Segmentierung auf 7-Worte / 3,5 s ist im Voice+Subtitle-Artikel detailliert erklärt — der Code-Pfad ist derselbe, nur das Eingangs-Audio ist hier das Master-MP4.

Wie der Assembly-Agent Robust gegen Sub-Failures ist

Im Pipeline-Orchestrator:

# 4. Thumbnail (Replicate Hero + Brand-Template via Playwright)
console.print(f"[cyan]→ Thumbnail-Generation...[/]")
try:
    thumb_r.render_thumbnail(script_path, thumb_dir)
except Exception as e:
    console.print(f"[yellow]Thumbnail fehlgeschlagen (non-fatal): {e}[/]")

# 5. Metadata (3 Titel + Description + Kapitel)
console.print(f"[cyan]→ Metadata-Generation...[/]")
try:
    meta_r.render_metadata(script_path, thumb_dir)
except Exception as e:
    console.print(f"[yellow]Metadata fehlgeschlagen (non-fatal): {e}[/]")

Was nach dem Assembly-Agent läuft (Thumbnail + Metadata) ist non-fatal. Wenn Thumbnail-Generation crashed, läuft die Pipeline weiter — das fertige MP4 ist da, nur das Thumbnail muss manuell ergänzt werden. Das ist eine bewusste Architektur-Entscheidung: das Hauptprodukt (Video) darf nicht an Cherry-on-Top-Stages scheitern.

Der Assembly-Agent selbst ist hingegen kritisch — wenn er crashed, gibt es kein Master-MP4. Daher hier kein try/except um die ffmpeg-Aufrufe, sondern explizites RuntimeError, das den ganzen Run mit Exit-Code 2 beendet.

“Diese Trennung ‘kritisch vs. non-fatal’ ist eine Architektur-Lektion, die ich auch in Nicht-KI-Pipelines anwende. Welche Stage ist Pflicht, welche ist Bonus? Beim Bonus mit Try/Except umgehen, beim Pflicht crashen lassen. Das macht die Pipeline-Logik klarer.”

Was der Assembly-Agent kostet

  • ffmpeg lokal: 0 € Cloud-Kosten, nur CPU-Zeit (~90 Sekunden für 6 Min Video)
  • Whisper lokal: 0 €, ~90 Sekunden auf der GPU

Gesamt: 0 € pro Video. Der Assembly-Agent ist die billigste Stage der Pipeline. Cloud-Alternativen (z.B. AWS MediaConvert, Cloudflare Stream) wären 0,30–0,80 € pro Video — bei 12 Videos pro Monat 4–10 € extra für etwas, das mein Rechner sowieso kann.

Was als nächstes in der Serie kommt

Im letzten Teil der Build-in-Public-Serie kommt die Kosten-Analyse pro Agent — was kostet jede einzelne Stage, wo sind die Hebel zur Skalierung, was würde sich ändern bei 100 Videos pro Tag statt 3 pro Woche. Spoiler: einer der Agents kostet 60 % der Gesamt-Pipeline, und es ist nicht der, den die meisten erwarten würden.

Meine Einschätzung

Nicht jede Stage in einer KI-Pipeline braucht ein LLM. Der Assembly-Agent ist mein bestes Beispiel dafür — er ist rein deterministisch, ruft kein Sprachmodell auf und ist trotzdem einer der wertvollsten Bausteine der gesamten Kette. In meinen eigenen Tests war oft genau das der Unterschied: Nicht alles mit LLM-Calls erschlagen, wenn ein gut konfiguriertes ffmpeg-Skript stabiler, schneller und billiger ist. Die echte Kunst beim Pipeline-Bau ist zu wissen, wo KI den Unterschied macht und wo klassisches Engineering reicht.

Quellen

  • [ffmpeg c
Porträt von Christian Ohle

Geschrieben von

Christian Ohle

Builder · Schmied der christianohle

Seit 2005 mit dem Web. Online-Marketing, Coding, lokale KI. Schreibt auf christianohle über Agents, MCP, lokale LLMs und Workflow-Automation — alles selbst getestet. Wöchentlicher Newsletter mit aktuellen News & Tutorials.