ffmpeg で字幕もしくは文字な動画の別解なお尻

ffmpeg な話ばっかしとるの。

ffmpeg で字幕もしくは文字な動画の別解

前提とか前置きとか

まず「ffmpeg で」の意味から。つまりこれの意味するところの半分以上は「hardsub」(※)を扱いたいかどうか、てこと。これが「ffmpeg で」を冠しない話ならば、つまり「動画ファイルに字幕を」ならば、これは「softsub」の話もありえる、てこと。今回の話は実際は両方の話だけれど、自分のニーズが元々は hardsub だったので、そちら方面を少し強調した見出しにしてみた。

※hardsub は要するに「絵として埋め込んでしまう」形式の字幕。softsub は「独立した字幕ファイル」のようなものがあり、「それを読み込むのはビューワの仕事」てなカラクリになってる形式の字幕で、当然 softsub が柔軟だが、あらゆるビューワがこれを扱えるというわけでもなく、標準も(例によって)「あるようでないし、ないようである」なので、言っちゃえば「hardsub が(硬直してようがなんだろうが)手っ取り早い」てこと。

ていうかね、この話、半分は実は前に書いてるんだけど、問題ありなので、それも含めて改めて、ってことだわ。

srt が「簡単すぎるほど簡単過ぎるほどに簡単」なのでずっとそれを使ってたけれども

例えばこれなんぞは srt を手書きして、ffmpeg で埋め込んだやーつ。

srt の形式はあまりにシンプルで例えばこんなだ:

1 1
2 00:00:00,000 --> 00:00:30,000
3 字幕ひとつめ。
4 
5 2
6 00:00:30,000 --> 00:01:00,000
7 字幕ふたつめ。
8 改行で複数行を素直に表現出来る。

この形式を見て「難し過ぎてわからんからけーえん」てやつぁおらぬであろう。そして「この形式を見て想像出来ること」以上のことは(ほぼ)何も出来ない。(実際は非公式の仕様で「html もどき」を少し使えたりもするが。)

そう、まさに「書いたことしか出来ない」、つまり位置指定も(このファイル形式そのものでは)出来ない。ので、ffmpeg なら「ffmpeg 自身の機能にどうにか頑張って頼って」制御することになる。というか研究不足で出来るのかどうか知らんけど。

ただこんなであれ、「動画ファイルのベース名と同じ」(例えば aaa.mp4 に対して aaa.srt)ならば「字幕を扱えるビューワ」、例えば VLC メディアプレイヤーや Media Player Classic は「hardsub らなくても」勝手に字幕を表示しようとしてくれる(断らなければ)。

「ffmpeg で hardsub を」としたい場合の唯一の鬼門は、まぁ「例によって」てことになるがフォントの件。*nix で野良ビルドするつもりなら libfreetype との格闘が最低限、とか色々だが、Windows の場合の問題は、「公式バイナリを拾ってきてもこれが含まれていない(fonts.conf)」てこと。どっかから持ってくるがよろしい。(読みにくい記事だけど以前ワタシが書いたもん読んでもらえればいい。)これさえ満たしてれば、基本的にはこんだけ:

元動画を aaa.mp4、作った字幕ファイルを aaa.srt として
1 [me@host: ~]$ ffmpeg -y -i aaa.mp4 -c:a copy -vf 'subtitles=aaa.srt' out.mp4

フィルタの例に漏れず、こういう複雑な使い方も出来る:

 1 #! /bin/sh
 2 
 3 ffmpeg -hide_banner -y \
 4   -i movie01.mp4 \
 5   -i movie02.mp4 \
 6   -i movie03.mp4 \
 7   -filter_complex "
 8 [0:v]scale=1280+160:720+90,subtitles=_movie01.srt,pad=1920:1080:0:0,setsar=1[v0];
 9 [1:v]scale=1280+160:720+90,subtitles=_movie02.srt,pad=1920:1080:ow-iw:oh-ih,setsar=1[v1];
10 [v1][v0]blend=all_mode=average[v];
11 
12 [0:a]pan=stereo|c0=c0|c1=c1[a0_0];
13 [1:a]pan=stereo|c0=c0|c1=c1[a1_0];
14 [a0_0][a1_0]amerge=inputs=2,pan=stereo|c0=c0|c1=c3[ac1];
15 [ac1][2:a]amerge=inputs=2,pan=stereo|c0<c0+c2|c1<c1+c3,volume=1.7[a]
16 " -map '[v]' -map '[a]' \
17   -color_primaries bt709 -color_trc bt709 -colorspace bt709 \
18   movie02__movie01.mp4

ただまぁこういう使い方は自分の書いたフィルタグラフを正しく理解できないうちは、ハマるのでやめたほうがいいかも。見かけがテキストだもんだからつい忘れてしまうけれど、要するに subtitles も「動画のフレームと同列」なので、同期する動画と辻褄の合わないことをすると、例えば終了しない動画になったり、あるいは入力群のフレーム数などが一致していないといけないようなフィルタがエラーを起こしたりなど色々喰らうであろうし。

話を戻すと。要するに「こうまでシンプルなので」、なんというかさ、こういうこととかする際の「デバッグ目的で」使えたりもして、とにかく「便利だよねー」と思っていたわけだわ。

そしてその「こうまでシンプルなので」そのそれそのものが問題となる日が来た、と。つまり「うー、自在に位置指定してーんですけど」。srt だけでも出来ないことはないような気もするけれど、たぶん相当めんどいに違いない。ほかのがあればあるんじゃね?

なのでお尻(ass)

ffmpeg が扱える形式はいくつかあって、その srt と ass ね。存在はずっと知ってたが、初めて手を出してみようかと。あ、「書けてれば」ffmpeg からの使い方やら Media Player Classic からの見え方やら全部共通ね。ffmpeg なら:

間違い探し
1 [me@host: ~]$ ffmpeg -y -i aaa.mp4 -c:a copy -vf 'subtitles=aaa.ass' out.mp4
間違い探し
 1 #! /bin/sh
 2 
 3 ffmpeg -hide_banner -y \
 4   -i movie01.mp4 \
 5   -i movie02.mp4 \
 6   -i movie03.mp4 \
 7   -filter_complex "
 8 [0:v]scale=1280+160:720+90,subtitles=_movie01.ass,pad=1920:1080:0:0,setsar=1[v0];
 9 [1:v]scale=1280+160:720+90,subtitles=_movie02.ass,pad=1920:1080:ow-iw:oh-ih,setsar=1[v1];
10 [v1][v0]blend=all_mode=average[v];
11 
12 [0:a]pan=stereo|c0=c0|c1=c1[a0_0];
13 [1:a]pan=stereo|c0=c0|c1=c1[a1_0];
14 [a0_0][a1_0]amerge=inputs=2,pan=stereo|c0=c0|c1=c3[ac1];
15 [ac1][2:a]amerge=inputs=2,pan=stereo|c0<c0+c2|c1<c1+c3,volume=1.7[a]
16 " -map '[v]' -map '[a]' \
17   -color_primaries bt709 -color_trc bt709 -colorspace bt709 \
18   movie02__movie01.mp4

書けてれば? wikipedia からの写経:

 1 [Script Info]
 2 ; Script generated by Aegisub
 3 ; http://www.aegisub.net
 4 Title: Neon Genesis Evangelion - Episode 26 (neutral Spanish)
 5 Original Script: RoRo
 6 Script Updated By: version 2.8.01
 7 ScriptType: v4.00+
 8 Collisions: Normal
 9 PlayResY: 600
10 PlayDepth: 0
11 Timer: 100,0000
12 Video Aspect Ratio: 0
13 Video Zoom: 6
14 Video Position: 0
15  
16 [V4+ Styles]
17 Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
18 Style: DefaultVCD, Arial,28,&H00B4FCFC,&H00B4FCFC,&H00000008,&H80000008,-1,0,0,0,100,100,0.00,0.00,1,1.00,2.00,2,30,30,30,0
19  
20 [Events]
21 Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
22 Dialogue: 0,0:00:01.18,0:00:06.85,DefaultVCD, NTP,0000,0000,0000,,{\pos(400,570)}Like an Angel with pity on nobody\NThe second line in subtitle

ざっくり3分ほど眺めて即座に想像したこと4つ:

  1. INIファイルっぺー
  2. csv っぺーがカラム指定出来るのかしら
  3. Dialogue を繰り返せばいいんかいね?
  4. \N で改行っぽいわね

それと当然「{\pos(400,570)}」が目に留まる。うむ、よさげ。

で、早速やってみたのであるが:

  1. やたら記述が多くて鬱陶しいのでミニマルで試そうと Script Info セクション丸ごと削ったらダメだった。最低でも「PlayResY」が必要。例では 600、とあるが…これはなんだろ、DPI かしら?
  2. 「カラム指定出来る」な理解は正しくて、Format: でカラム名を削ればその位置に対応する値を書かなくてよくなるが、これも「ミニマルから」と、Styles の FontSize 以降全部削ってみたらやはり NG。つーかさぁ、Encoding、そんなに大事なんなら最後に置くなよ。そう、Encoding は削ったらいかん。
  3. 時刻指定の形式がやたら「厳格」(というか繊細というかボロいというか)で、「0:00:01.18」以外のどんな形式も許容しない、らしい。秒未満の指定を3桁、つまり「0:00:01.182」とかにしたら「ヘンなこと」になった。「ヘン」はもうなんというか「時代錯誤」的なヘンさ。なんつーかね、たぶん処理プログラムが文字数固定で読み込んでる。C 言語なら sscanf なんぞで文字数固定で読み込みしてる感じ。あのねいまさらバカなの死ぬの。

とまぁ、30分くらいは悶絶してたんだけど、言っちゃえばこんだけで、始めるのは簡単だし、なんであれ結構自由だ。「書きやすい」とは到底思えないけれど、やりたいことは結構出来る。てわけで、ワタシの目的のものはたとえばこんな風に書いた:

 1 [Script Info]
 2 PlayResY: 600
 3 
 4 [V4+ Styles]
 5 Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
 6 Style: DefaultVCD, Arial,46,&H004A4A4A,&H004A4A4A,&H00303030,&H80000008,-1,0,0,0,100,100,0.00,0.00,1,1.00,2.00, 1 ,30,30,30,0
 7 
 8 [Events]
 9 Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
10 Dialogue: 0,00:00:00.00,00:06:04.90,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N00:00.0 - 06:04.9
11 Dialogue: 0,00:00:00.00,00:06:04.90,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}1-22\N17:34.1 - 23:39.0
12 Dialogue: 0,00:06:04.90,00:07:50.90,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N06:04.9 - 07:50.9
13 Dialogue: 0,00:06:04.90,00:07:50.90,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}1-23beta\N00:00.0 - 01:46.0
14 Dialogue: 0,00:07:50.90,00:10:36.90,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N07:50.9 - 10:36.9
15 Dialogue: 0,00:07:50.90,00:10:36.90,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}1-23beta\N03:17.0 - 06:03.0
16 Dialogue: 0,00:10:36.90,00:10:55.90,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N10:36.9 - 10:55.9
17 Dialogue: 0,00:10:36.90,00:10:55.90,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}1-23beta\N08:36.0 - 08:55.0
18 Dialogue: 0,00:10:55.90,00:11:29.90,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N10:55.9 - 11:29.9
19 Dialogue: 0,00:10:55.90,00:11:29.90,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}1-23beta\N09:27.0 - 10:01.0
20 Dialogue: 0,00:11:29.90,00:11:43.90,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N11:29.9 - 11:43.9
21 Dialogue: 0,00:11:29.90,00:11:43.90,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}1-23beta\N10:50.0 - 11:04.0
22 Dialogue: 0,00:11:43.90,00:12:43.90,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N11:43.9 - 12:43.9
23 Dialogue: 0,00:11:43.90,00:12:43.90,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}1-23beta\N11:16.0 - 12:16.0
24 Dialogue: 0,00:12:43.90,00:14:22.80,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N12:43.9 - 14:22.8
25 Dialogue: 0,00:12:43.90,00:14:22.80,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}1-23beta\N12:27.0 - 14:05.9
26 Dialogue: 0,00:14:22.80,00:17:45.60,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N14:22.8 - 17:45.6
27 Dialogue: 0,00:14:22.80,00:17:45.60,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}1-23beta\N14:08.2 - 17:31.0
28 Dialogue: 0,00:17:45.60,00:19:17.60,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N17:45.6 - 19:17.6
29 Dialogue: 0,00:17:45.60,00:19:17.60,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}1-23beta\N17:41.0 - 19:13.0
30 Dialogue: 0,00:19:17.60,00:22:14.60,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N19:17.6 - 22:14.6
31 Dialogue: 0,00:19:17.60,00:22:14.60,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}1-23beta\N20:42.0 - 23:39.0
32 Dialogue: 0,00:22:14.60,00:24:00.12,DefaultVCD, NTP,0,0,0,,{\pos(550,180)}0-23\N22:14.6 - 24:00.1
33 Dialogue: 0,00:22:14.60,00:24:00.12,DefaultVCD, NTP,0,0,0,,{\pos(10,510)}0-01\N00:00.0 - 01:45.5

DefaultVCD なんてそのまま使ってるが、無論これはサンプルがそうしてただけのことであって、これは「自分で定義したスタイルの名前」なので、ここを「自由に使いこな」せば、Dialogue ごとに違うスタイルを使える。ありがちなのは「牧瀬紅莉栖なので栗毛色、椎名まゆりなので水色」みたいな、字幕なニーズにはすごくありがちなことを素直に記述出来る、つーことね。

現時点で全貌のほとんど理解できてないけれど、あとは www.matroska.org でチマチマ仕様を調べていけば、かなり細かい制御は出来ろう、きっと。

2019-01-01 04:30 追記: 微ハマリ (エスケープせねばん)

こんなんを作っていたのよ:

「ffmpeg で音声を可視化すんべー」なネタなわけなんだけれど、最初の45秒の間出してるプログラムコードはこのお尻で作ってるわけなのよ。こんな「自家発電」で:

 1 #! /bin/sh
 2 ifn="Air on the G String (from Orchestral Suite no. 3, BWV 1068).mp3"
 3 ifnb="`basename \"${ifn}\" .mp3`"
 4 pref="`basename $0 .sh`"
 5 
 6 # --CUT BEGIN
 7 cat <<__EOF__ > ${pref}.ass
 8 [Script Info]
 9 PlayResY: 600
10  
11 [V4+ Styles]
12 Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
13 Style: TrkNm, Arial,30,&H00B0B0B0,&H00B0B0B0,&H00303030,&H80000008,-1,0,0,0,100,100,0.00,0.00,1,1.00,2.00, 1 ,30,30,30,0
14 
15 [Events]
16 Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
17 Dialogue: 0,00:00:00.00,00:00:45.00,TrkNm, NTP,0,0,0,,{\pos(10,400)}`sed '5,19d' $0 | sed "s@,subtitles=.*.ass@@" | sed 's@-t 183 @@' | py -2 -c 'import sys ; print("\\N".join([line.rstrip().replace("{", "\\{").replace("}", "\\}") for line in sys.stdin.readlines()]))'`
18 __EOF__
19 # --CUT END
20 
21 #
22 ffmpeg -y -i "${ifn}" -filter_complex "
23 [0:a]showcqt=s=1920x1080,subtitles='${pref}.ass'[vcqt];
24 [0:a]showwaves=split_channels=1:mode=line:s=1280x480[vs];
25 [vcqt][vs]overlay=x=W-w-50:y=50[v]
26 " \
27     -map '[v]' -map '0:a' -c:a copy \
28     -t 183 "${pref}_${ifnb}.mp4"

ちょっくら入り組んでるようにみえるかもしらんが大したことやってるわけではなく、「自分自身」(シェルスクリプト)を加工しておケツ(ass)に仕立て上げて埋め込んでおるわけね。そーか…、中括弧はエスケープしないとダメよね、って話。{\pos(10,400)} みたいな制御が出来るわけなんだから。

2019-01-13 20:00 追記: 結構なハマリ (ffmpeg 独自な振る舞いと Alignment、Font)

当座の目的の成果物たち:

こういう「動画さえ作れちゃえばおけ」、つまり刹那的であるぶんにはまぁ困り度はさほどでもないとは言えるのだけれど、3つばかり、弱っていた。

一つは「ffmpeg に搭載の SSA/ASS 処理エンジンがどうやら独特らしい」ということ。ワタシが上で挙げた実例は、ffmpeg で hardsub るぶんには機能するけれど、これがまぁ VLC Media Player だとか Windows Media Player Classic が拒絶しちゃう。かなり特殊文字ではじかれてしまうらしい。つまり刹那的でない用途には使えない(作ったお尻単独で配布出来ない)。ワタシ的にはまさに刹那的利用だけがゴールなのでいいちゃぁいいんだけれど、リビジョン管理なんぞを考え出すと頭いたくなってくる。

Alignment については「調べもしなかったが、作業しながらずっと困っていた」状態だったが、「調べたらもっと弱ったことになった」。

つまり、最初に参考にした ass に基づくと「左寄せ下寄せ」なのだわ。これは「字幕」という性質を考えるなら妥当なデフォルトだとは思う。一行だった場合と複数行だった場合で一番下が揃っていないのは困るであろう。けれどもワタシがやっていたのはそうではないわけだ。スクリプト全文を貼り付けている。行数が可変なので、「行数が多いほど上に伸びていく」んじゃぁ、毎度 \pos 調整しなければならず、大変鬱陶しい。

なのでいい加減正確なスペックを知りたいぞ、と、この鬱陶しいワードをよっこらせと調べ始めた。ところが…:

Field 13: Alignment. This sets how text is “justified” within the Left/Right onscreen margins, and also the vertical placing. Values may be 1=Left, 2=Centered, 3=Right. Add 4 to the value for a “Toptitle”. Add 8 to the value for a “Midtitle”. eg. 5 = left-justified toptitle

1、2、3 についてはどうやら「おっさるとおり」なんだわ。けど「eg. 5 = left-justified toptitle」がね、ガセにしか思えぬ。仕方なくトライアンドエラーし、どうやら「7」がワタシが求めるものだとわかったけれど、このスペックによればこれって「7 = right justified toptitle」なのではないの? ワガラン。ffmpeg 相手でしか試してないが、ffmpeg のがオカシイんではないかという気はする。

フォントについては「絶賛困りはじめ」つーかね、今まで本腰ってなくて、「Arial だけが世界だ」で我慢してた。モノスペースなフォントにしたいのだけどね、まぁ bash なスクリプトならプロポーショナルでもわからんこともないしなてことで。そうなんだけどさ、これもスペックをチラ見してちょっとビビり始めてる。まず、Fonts セクション:

[Fonts]
This section contains text-encoded font files, if the user opted to embed non-standard fonts in the script. Only truetype fonts can be embedded in SSA scripts. Each font file is started with a single line in the format:
fontname: <name of file>
The word “fontname” must be in lower case (upper case will be interpretted as part of a text-encoded file).
is the file name that SSA will use when saving the font file. It is:
the name of the original truetype font,
plus an underscore,
plus an optional “B” if Bold,
plus an optional “I” if Italic,
plus a number specifying the font encoding (character set),
plus “.ttf”
Eg.
fontname: chaucer_B0.ttf
fontname: comic_0.ttf
The fontname line is followed by lines of printable characters, representing the binary values which make up the font file. Each line is 80 characters long, except the last one which may be less. The conversion from binary to printable characters is a form of Uuencoding, the details of this encoding is contained in “Appendix B”, below.

ぬぬ。これを不可欠で書かなければならないのかどうかはまだわからんけれど、なんか独特なことを言ってる気がする。

で、「[V4+ Styles]」に書く FontName:

Field 2: Fontname. The fontname as used by Windows. Case-sensitive.

なんだ? なんで「Windows」? プラットフォーム依存なの、この子? これは弱るよな、ヒトによっては。(特に独立させて配布するのが目的なら。) うーん、でも ffmpeg は多分自分で置いた「fonts.conf」に従うんだろうなぁ、ffmpeg はこのスペックとは違う振る舞いをしそうだ、と思い、仮で「MS Gothic」に変更してみたら、一応 ffmpeg はこれを試みてくれるらしい:

1 [Parsed_subtitles_1 @ 00000000039ba540] fontselect: (MS Gothic, 700, 0) -> MS-Gothic, 0, MS-Gothic

で自分で置いた fonts.conf を覗き込んでみたら「monospace」なんてのが見つかったので、これにしてみると:

1 [Parsed_subtitles_1 @ 00000000037592c0] fontselect: (monospace, 700, 0) -> CourierNewPS-BoldMT, 0, CourierNewPS-BoldMT

うむ、これはまごうことなき monospace。でもさぁ。

  1. 行頭の空白が取っ払われちゃった。貼り付けるコードが Python だとこれはめっさ困る。
  2. やっぱしこれって「ffmpeg だから」な振る舞いのような気がする。なのでこのスペックをどう読むのが正解なのか、判断しかねている。

てわけで、まだまだ絶賛弱り中。今日のオレ、には関係なくても、明日の尻アスカには関係あるかもしれん。

文字だけな動画

以前「嫌いでしょ? わたしも嫌いだよ」という前置きで書いたネタの、まぁ別解ともなるわけだよ、このお尻。この程度まで自由度があるならば。

ffmpeg なら、あるいは「drawtext」なんぞを駆使するよりか、きっと圧倒的簡単、と思う。結構さ、「一括で動画内にタイトルを埋め込みたい」なんてニーズ、ワタシにはあるのだけれど、そんなときにさ、drawtext を駆使して頑張るのって、かな~り苦痛、だと思う。(というかそれがイヤで、前のネタでは Pillow と PyAV でやったわけなのだ。)

スクリプト言語とかで自動生成しやすい形式、でもあるしね。