0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ffmpegで縦動画に焼き込んだ字幕が、指定した位置に出ないのはなぜか

0
Posted at

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%の帯まで入り込めてしまいます。

覆われる帯に色を付けた同じフレーム。左からMarginV=28・Fontsize=22、MarginV=81・Fontsize=22、実ピクセルで指定したもの

実ピクセルで指定する

いちばん簡単なのは、スクリプトのキャンバスを動画と同じ大きさだと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 がこうした処理を手元のパソコンで済ませる考え方は、ローカルファーストのページ にあります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?