Burning captions into a 1080×1920 video for TikTok or Reels usually starts with a reasonable plan: the feed covers roughly the bottom fifth of the frame, so keep the caption above that. With ffmpeg's subtitles filter you reach for force_style and set MarginV. Then you render, and the caption is somewhere else entirely, and much bigger than expected.
The reason is that an .srt file has no idea how large your video is. ffmpeg turns it into an ASS script with a fixed, tiny canvas, and every number in force_style is read in that canvas's units. Below are measurements on a real 1080×1920 render, and a force_style that puts the caption where the arithmetic says it should be.
The 384×288 canvas behind every .srt
Convert any .srt with ffmpeg and look at the header:
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 and PlayResY: 288 are the script's coordinate system, a small 4:3 canvas. libass scales it to the frame it renders on: vertical values by 1920 / 288 = 6.67, horizontal ones by 1080 / 384 = 2.81. MarginV, Fontsize, Outline, MarginL and MarginR in force_style are all in those script pixels.
So MarginV=28 is not "28 pixels" and not "28% of the height". On a 1920-pixel frame it is about 187 pixels. Fontsize=22 is about 147 pixels. I checked the second one directly: a capital H at Fontsize=22 and at PlayResY=1920,Fontsize=146.67 covers exactly the same pixels (rows 1258 to 1351).
Measuring where the text lands
To get numbers rather than impressions, render one caption onto a flat grey 1080×1920 frame, read the frame back as raw grey bytes, and find the white pixels. Python's standard library is enough:
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 The caption in one.srt is a real 38-character line from a transcript, "the Saharan dust cloud that we've seen", shown from 0 to 2 seconds. The threshold of 200 catches the white fill and ignores the black outline; a gap of more than 20 empty rows counts as a new line of text.
The results
The target is the one the osFoundry video editor uses for 9:16: the bottom 20% of the frame is covered by the feed, and captions sit 72% of the way down, which on 1920 pixels means the text should end around row 1382.
| force_style | Text top | Text bottom | Lines |
|---|---|---|---|
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 |
Three things show up.
MarginV=28, the number you get if you treat the margin as a percentage (100 − 72), leaves the last line at 89% of the height, deep inside the covered band. MarginV=81 is the same 28% expressed in script pixels (0.28 × 288), and the bottom edge does land at 70%.
Fixing the margin alone isn't enough, though. At Fontsize=22 the 38-character line becomes three lines of 147-pixel text, and because the caption is anchored at the bottom, extra lines grow upwards. The top of the caption reaches 50% of the frame, which on a talking-head clip is the speaker's chest or face.
The sides have the same problem in the other direction. The default MarginL=10 and MarginR=10 become about 28 pixels on a 1080-wide frame (moving MarginL from 10 to 23 shifted the text by 37 pixels, 2.85 per unit), so wrapped text is allowed to run into the 6% strips where the like and share buttons live.
Work in real pixels instead
The simplest fix is to tell libass that the script canvas is the video. PlayResX and PlayResY are script-info fields, but libass accepts them in force_style as well, and then every margin and size is in frame pixels:
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", ]) That produces PlayResX=1080,PlayResY=1920,Alignment=2,MarginV=538,MarginL=80,MarginR=80,Fontsize=80,Outline=5, the last row of the table: two lines, bottom edge at 71.1%, and the text between x = 200 and 877, well clear of both 65-pixel side strips. libass's default smart wrapping splits the line into two of similar width instead of one long and one short.
On the command line:
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 One detail when you do the arithmetic: MarginV measures to the bottom of the line box, which includes room for descenders, not to the baseline. With MarginV=540 the bottom of a capital H sat at row 1351, about 29 pixels above 1920 − 540 = 1380. If a few pixels matter, measure your own font rather than trusting the number.
If you'd rather not depend on force_style, writing the .ass file yourself with PlayResX: 1080 and PlayResY: 1920 in the header gives the same result and lets you style each line separately.
How much the burn costs
On a 32-second 1080×1920 clip with 13 captions, the burn with the style above took 13.2 seconds on an M1 Mac with ffmpeg 7.1 and default libx264 settings. Re-encoding the same clip with no subtitle filter took 13.3 seconds. Drawing the text is lost in the noise; the encode is the whole cost.
Which band to aim for
The safe-area numbers aren't one standard. The figures above are the osFoundry editor's own (SAFE_AREAS in its canvas code: 9:16 is top 8%, bottom 20%, sides 6%), and they describe what a feed draws over an ordinary post. The platforms publish guidance mainly for ads:
| Placement | Keep free | Source |
|---|---|---|
| Instagram Reels ads | top 14%, bottom 35%, sides 6% | Meta Ads Guide, Reels video specs |
| TikTok in-feed ads | depends on the ad caption length; downloadable safe-zone files, separate ones for right-to-left regions and for ads with anchors | TikTok Ads help center |
| YouTube Shorts ads | a 1080×1920 safe-zone template; overlays vary by format | Google Ads help |
| osFoundry editor, 9:16 | top 8%, bottom 20%, sides 6% | SAFE_AREAS |
For an ad on Reels, the same function with bottom=0.35 and a baseline of 0.62 or so keeps the caption above Meta's band. The point of computing the style instead of typing it is that switching targets is one argument.
The app derives the caption height from the safe area in one small function, which says why in its comment:
/// 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, } } The desktop app with that editor is free from the osFoundry download page, and more about how osFoundry keeps this kind of work on your own machine is on its local-first page.


