Untertitel in ein 1080×1920-Video für TikTok oder Reels einzubrennen beginnt meist mit einem vernünftigen Plan: Der Feed verdeckt ungefähr das untere Fünftel des Bildes, also soll der Untertitel darüber stehen. Beim subtitles-Filter von ffmpeg greift man zu force_style und setzt MarginV. Dann rendert man, und der Untertitel steht ganz woanders und ist deutlich größer als gedacht.

Der Grund: Eine .srt-Datei weiß nicht, wie groß das Video ist. ffmpeg macht daraus ein ASS-Skript mit einer festen, winzigen Leinwand, und jede Zahl in force_style wird in den Einheiten dieser Leinwand gelesen. Unten stehen Messungen an einem echten 1080×1920-Render und ein force_style, das den Untertitel genau dorthin setzt, wo die Rechnung ihn haben will.

Die 384×288-Leinwand hinter jeder .srt

Konvertiere eine beliebige .srt mit ffmpeg und schau dir den Header an:

ffmpeg -i one.srt one.ass && head -12 one.ass 
[Script Info] ; Script generated by FFmpeg/Lavc61.19.100 ScriptType: v4.00+ PlayResX: 384 PlayResY: 288 ScaledBorderAndShadow: yes ... Style: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1 

PlayResX: 384 und PlayResY: 288 sind das Koordinatensystem des Skripts, eine kleine 4:3-Leinwand. libass skaliert sie auf das Bild, in das gerendert wird: vertikale Werte mit 1920 / 288 = 6.67, horizontale mit 1080 / 384 = 2.81. MarginV, Fontsize, Outline, MarginL und MarginR in force_style sind alle in diesen Skript-Pixeln angegeben.

MarginV=28 heißt also weder "28 Pixel" noch "28 % der Höhe". Bei einem 1920 Pixel hohen Bild sind das etwa 187 Pixel. Fontsize=22 sind etwa 147 Pixel. Das Zweite habe ich direkt geprüft: Ein großes H mit Fontsize=22 und mit PlayResY=1920,Fontsize=146.67 belegt exakt dieselben Pixel (Zeilen 1258 bis 1351).

Messen, wo der Text landet

Um Zahlen statt Eindrücke zu bekommen, rendert man einen Untertitel auf ein einfarbig graues 1080×1920-Bild, liest das Bild als rohe Graustufen-Bytes zurück und sucht die weißen Pixel. Dafür reicht die Python-Standardbibliothek:

import subprocess, sys FF = sys.argv[1] # path to an ffmpeg built with libass W, H = 1080, 1920 def text_box(style, srt="one.srt"): """Burn one caption onto a grey 1080x1920 frame and return where the white text landed.""" vf = f"subtitles={srt}:force_style='{style}',format=gray" raw = subprocess.run( [FF, "-v", "error", "-f", "lavfi", "-i", f"color=c=0x303030:s={W}x{H}:d=2", "-vf", vf, "-ss", "1", "-frames:v", "1", "-f", "rawvideo", "-"], capture_output=True, check=True).stdout pts = [(i // W, i % W) for i, v in enumerate(raw) if v > 200] rows = sorted({y for y, _ in pts}) lines = 1 + sum(1 for a, b in zip(rows, rows[1:]) if b - a > 20) xs = [x for _, x in pts] return rows[0], rows[-1], min(xs), max(xs), lines 

Der Untertitel in one.srt ist eine echte 38 Zeichen lange Zeile aus einem Transkript, "the Saharan dust cloud that we've seen", sichtbar von 0 bis 2 Sekunden. Der Schwellwert von 200 erfasst die weiße Füllung und ignoriert die schwarze Kontur; eine Lücke von mehr als 20 leeren Zeilen zählt als neue Textzeile.

Die Ergebnisse

Das Ziel ist das, was der Videoeditor von osFoundry für 9:16 verwendet: Die unteren 20 % des Bildes verdeckt der Feed, und Untertitel sitzen bei 72 % der Höhe. Bei 1920 Pixeln sollte der Text also etwa bei Zeile 1382 enden.

force_style Textoberkante Textunterkante Zeilen
MarginV=10,Fontsize=22 1437 (74.8%) 1826 (95.1%) 3
MarginV=28,Fontsize=22 1317 (68.6%) 1706 (88.9%) 3
MarginV=81,Fontsize=22 964 (50.2%) 1352 (70.4%) 3
PlayResX=1080,PlayResY=1920,MarginV=538,Fontsize=80 1235 (64.3%) 1366 (71.1%) 2

Drei Dinge fallen auf.

MarginV=28, also die Zahl, die herauskommt, wenn man den Rand als Prozentwert behandelt (100 − 72), lässt die letzte Zeile bei 89 % der Höhe stehen, tief im verdeckten Bereich. MarginV=81 ist dieselben 28 %, ausgedrückt in Skript-Pixeln (0.28 × 288), und damit landet die Unterkante tatsächlich bei 70 %.

Nur den Rand zu korrigieren reicht allerdings nicht. Bei Fontsize=22 werden aus der 38-Zeichen-Zeile drei Zeilen mit 147 Pixel hohem Text, und weil der Untertitel unten verankert ist, wachsen zusätzliche Zeilen nach oben. Die Oberkante des Untertitels reicht bis 50 % des Bildes, bei einem Talking-Head-Clip also bis zur Brust oder ins Gesicht der sprechenden Person.

An den Seiten gibt es dasselbe Problem in die andere Richtung. Die Standardwerte MarginL=10 und MarginR=10 werden bei einem 1080 Pixel breiten Bild zu etwa 28 Pixeln (MarginL von 10 auf 23 zu setzen verschob den Text um 37 Pixel, 2.85 pro Einheit). Umbrochener Text darf also in die 6-%-Streifen laufen, in denen die Like- und Teilen-Buttons liegen.

Dasselbe Bild in drei Varianten, der verdeckte Bereich schattiert: MarginV=28 bei Fontsize=22, MarginV=81 bei Fontsize=22 und die Version in echten Pixeln

Stattdessen in echten Pixeln arbeiten

Die einfachste Lösung ist, libass zu sagen, dass die Skript-Leinwand das Video ist. PlayResX und PlayResY sind Felder aus dem Script-Info-Abschnitt, aber libass akzeptiert sie auch in force_style, und dann sind alle Ränder und Größen in Bildpixeln:

def safe_style(top=0.08, bottom=0.20, sides=0.06, baseline=0.72, size=80): """force_style that works in real pixels, with the caption sitting on `baseline`.""" return ",".join([ f"PlayResX={W}", f"PlayResY={H}", "Alignment=2", f"MarginV={round((1 - baseline) * H)}", f"MarginL={round(sides * W) + 15}", f"MarginR={round(sides * W) + 15}", f"Fontsize={size}", "Outline=5", ]) 

Das ergibt PlayResX=1080,PlayResY=1920,Alignment=2,MarginV=538,MarginL=80,MarginR=80,Fontsize=80,Outline=5, die letzte Zeile der Tabelle: zwei Zeilen, Unterkante bei 71.1 % und der Text zwischen x = 200 und 877, mit deutlichem Abstand zu beiden 65 Pixel breiten Seitenstreifen. Der standardmäßige Smart-Wrapping-Modus von libass teilt die Zeile in zwei ungefähr gleich breite Zeilen statt in eine lange und eine kurze.

Auf der Kommandozeile:

ffmpeg -i in.mp4 -vf "subtitles=captions.srt:force_style='PlayResX=1080,PlayResY=1920,Alignment=2,MarginV=538,MarginL=80,MarginR=80,Fontsize=80,Outline=5'" -c:a copy out.mp4 

Ein Detail beim Nachrechnen: MarginV misst bis zur Unterkante der Zeilenbox, die Platz für Unterlängen enthält, nicht bis zur Grundlinie. Mit MarginV=540 saß die Unterkante eines großen H bei Zeile 1351, etwa 29 Pixel über 1920 − 540 = 1380. Wenn es auf ein paar Pixel ankommt, miss deine eigene Schrift, statt der Zahl zu vertrauen.

Wer nicht von force_style abhängen will, schreibt die .ass-Datei selbst mit PlayResX: 1080 und PlayResY: 1920 im Header. Das liefert dasselbe Ergebnis und erlaubt, jede Zeile einzeln zu stylen.

Was das Einbrennen kostet

Bei einem 32 Sekunden langen 1080×1920-Clip mit 13 Untertiteln dauerte das Einbrennen mit dem obigen Stil 13.2 Sekunden auf einem M1-Mac mit ffmpeg 7.1 und den Standardeinstellungen von libx264. Derselbe Clip ohne Untertitelfilter neu zu encodieren dauerte 13.3 Sekunden. Das Zeichnen des Texts geht im Rauschen unter; die Kosten stecken komplett im Encoding.

Welchen Bereich man anpeilen sollte

Die Safe-Area-Werte sind kein einheitlicher Standard. Die Zahlen oben sind die des osFoundry-Editors selbst (SAFE_AREAS im Canvas-Code: 9:16 bedeutet oben 8 %, unten 20 %, seitlich 6 %), und sie beschreiben, was ein Feed über einen normalen Post legt. Die Plattformen veröffentlichen Vorgaben vor allem für Anzeigen:

Platzierung Freihalten Quelle
Instagram-Reels-Anzeigen oben 14 %, unten 35 %, seitlich 6 % Meta Ads Guide, Videospezifikationen für Reels
TikTok-In-Feed-Anzeigen hängt von der Länge des Anzeigentexts ab; Safe-Zone-Dateien zum Herunterladen, eigene für Rechts-nach-links-Regionen und für Anzeigen mit Anchors TikTok-Ads-Hilfecenter
YouTube-Shorts-Anzeigen eine 1080×1920-Safe-Zone-Vorlage; Overlays variieren je nach Format Google-Ads-Hilfe
osFoundry-Editor, 9:16 oben 8 %, unten 20 %, seitlich 6 % SAFE_AREAS

Für eine Anzeige auf Reels hält dieselbe Funktion mit bottom=0.35 und einer Grundlinie von etwa 0.62 den Untertitel über Metas Bereich. Der Sinn davon, den Stil zu berechnen statt ihn abzutippen: Ein anderes Ziel ist nur ein anderes Argument.

Die App leitet die Untertitelhöhe in einer kleinen Funktion aus der Safe Area ab, und der Kommentar sagt, warum:

/// Where captions sit, as a fraction of frame height from the top. /// /// 🔴 Derived from the platform safe area, not from a fixed margin. On a vertical feed the /// bottom fifth of the frame is under the platform's own caption and buttons, so a caption /// placed at the conventional 90% is guaranteed to be covered — which is the whole reason C1 /// shipped the safe-area overlay. pub fn caption_baseline(aspect: &str) -> f64 { match aspect { "9:16" => 0.72, "1:1" => 0.84, _ => 0.88, } } 

Die Desktop-App mit diesem Editor gibt es kostenlos auf der Download-Seite von osFoundry, und wie osFoundry diese Art von Arbeit auf dem eigenen Rechner hält, steht auf der Local-first-Seite.