TikTokやリール向けの1080×1920の動画に字幕を焼き込むとき、考えることはだいたい同じです。フィードのUIが下の5分の1くらいを覆うので、字幕はそれより上に置きたい。ffmpegの subtitles フィルターなら force_style で MarginV を指定すればいいはず。ところがレンダリングしてみると、字幕は思ったのと違う場所に、思ったよりずっと大きく出てきます。
原因は、.srt ファイルが動画の大きさを知らないことです。ffmpegは .srt を、決まった小さなキャンバスを持つASSスクリプトに変換し、force_style の数値はすべてそのキャンバスの単位で解釈されます。以下では、実際の1080×1920のレンダリングで位置を測り、計算どおりの場所に字幕を出す force_style を作ります。
.srt の裏にある 384×288 のキャンバス
適当な .srt をffmpegでASSに変換して、ヘッダーを見てみます。
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 と PlayResY: 288 がスクリプトの座標系で、4:3の小さなキャンバスです。libassはこれを描画先のフレームに合わせて拡大します。縦方向は 1920 / 288 = 6.67倍、横方向は 1080 / 384 = 2.81倍。force_style で指定する MarginV、Fontsize、Outline、MarginL、MarginR は、どれもこのスクリプト上のピクセルです。
つまり MarginV=28 は「28ピクセル」でも「高さの28%」でもなく、1920ピクセルのフレームでは約187ピクセルになります。Fontsize=22 は約147ピクセルです。後者は直接確かめました。大文字のHを Fontsize=22 で描いたものと、PlayResY=1920,Fontsize=146.67 で描いたものは、まったく同じピクセル(1258行目から1351行目)を占めます。
文字がどこに出たかを測る
印象ではなく数字で見るために、無地のグレーの1080×1920フレームに字幕を1つ焼き込み、そのフレームをグレーの生バイトで読み戻して、白いピクセルの範囲を探します。Pythonの標準ライブラリだけで足ります。
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
one.srt の字幕は、実際の文字起こしから取った38文字の1行「the Saharan dust cloud that we've seen」で、0〜2秒に表示します。しきい値200で白い文字本体だけを拾い、黒い縁取りは無視します。白いピクセルのない行が20行より多く続いたら、次の行の文字とみなします。
結果
目標は、osFoundryの動画エディターが9:16で使っている値です。フレームの下20%はフィードに覆われ、字幕は上から72%の位置に置きます。1920ピクセルなら、文字の下端が1382行目あたりに来れば正解です。
| force_style | 文字の上端 | 文字の下端 | 行数 |
|---|---|---|---|
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 |
ここから3つのことがわかります。
余白をパーセントのつもりで計算した MarginV=28(100 − 72)では、最後の行が高さの89%に来ます。覆われる帯のど真ん中です。同じ28%をスクリプトのピクセルに直した MarginV=81(0.28 × 288)なら、下端はちゃんと70%に収まります。
ただ、余白を直すだけでは足りません。Fontsize=22 のままだと、38文字の行は147ピクセルの文字で3行に折り返されます。字幕は下端を基準に置かれるので、行が増えると上へ伸びていきます。字幕の上端はフレームの50%まで届き、人が話している動画なら胸や顔にかかります。
左右も同じ落とし穴です。既定の MarginL=10・MarginR=10 は、横1080ピクセルのフレームでは約28ピクセルにしかなりません(MarginL を10から23に変えると、文字は37ピクセル、1あたり2.85ピクセル動きました)。折り返した文字が、いいねやシェアのボタンが並ぶ左右6%の帯まで入り込めてしまいます。
実ピクセルで指定する
いちばん簡単なのは、スクリプトのキャンバスを動画と同じ大きさだとlibassに教えることです。PlayResX と PlayResY はScript Infoの項目ですが、libassは force_style の中でも受け付けます。そうすれば、余白も文字サイズもすべてフレームのピクセルで書けます。
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",
])
出力は PlayResX=1080,PlayResY=1920,Alignment=2,MarginV=538,MarginL=80,MarginR=80,Fontsize=80,Outline=5 で、表の最後の行がこれです。2行に収まり、下端は71.1%、文字は x = 200〜877 の範囲で、左右65ピクセルの帯から十分離れています。libassの既定の折り返しは、長い行と短い行ではなく、同じくらいの幅の2行に分けてくれます。
コマンドにするとこうなります。
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
計算するときの注意がひとつあります。MarginV が測るのはベースラインまでではなく、ディセンダー分の余裕を含んだ行の箱の下端までです。MarginV=540 のとき、大文字Hの下端は1351行目で、1920 − 540 = 1380 より約29ピクセル上でした。数ピクセルが効く場面では、数値を信じるより自分のフォントで測ったほうが確実です。
force_style に頼りたくなければ、ヘッダーに PlayResX: 1080 と PlayResY: 1920 を書いた .ass ファイルを自分で作っても同じ結果になり、行ごとにスタイルも変えられます。
焼き込みにかかる時間
字幕13個、長さ32秒の1080×1920のクリップに上のスタイルで焼き込むと、M1のMac、ffmpeg 7.1、libx264の既定設定で13.2秒かかりました。字幕フィルターなしで同じクリップを再エンコードすると13.3秒です。文字を描く分は誤差に埋もれていて、時間はほぼすべてエンコードです。
どの帯を避けるか
セーフエリアの数字は、ひとつの標準があるわけではありません。ここまでの値はosFoundryのエディター自身のもの(キャンバスのコードの SAFE_AREAS。9:16は上8%、下20%、左右6%)で、通常の投稿にフィードが重ねる範囲を表しています。各プラットフォームが公開しているのは、主に広告向けのガイドです。
| 配置先 | 空けておく範囲 | 出典 |
|---|---|---|
| Instagramリール広告 | 上14%、下35%、左右6% | Metaの広告ガイド(リール動画の仕様) |
| TikTokインフィード広告 | 広告文の長さで変わる。セーフゾーンのファイルを配布(右から左に書く地域用、アンカー付き広告用は別) | TikTok広告ヘルプセンター |
| YouTubeショート広告 | 1080×1920のセーフゾーンのテンプレート。重なるUIは形式で変わる | Google広告ヘルプ |
| osFoundryのエディター(9:16) | 上8%、下20%、左右6% | SAFE_AREAS |
リール広告なら、同じ関数に bottom=0.35 と0.62前後のベースラインを渡せば、Metaの帯より上に収まります。スタイルを手で打たずに計算で出しておくと、狙う先を変えるのが引数ひとつで済みます。
アプリ側では、字幕の高さをセーフエリアから決める小さな関数があり、その理由がコメントに書かれています。
/// 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,
}
}
このエディターを含むデスクトップアプリは osFoundryのダウンロードページ から無料で入手できます。osFoundry がこうした処理を手元のパソコンで済ませる考え方は、ローカルファーストのページ にあります。
