ffmpeg で動画分割(とか)

今回も若干タイトルを曖昧にしてる。ちょうどffmpeg で複数動画結合の反対のニーズね。

ffmpeg で動画分割(とか)

動機

こんなん簡単なハズだ、と思ったりするわけだ。一度書いてたりもするし

けど「ほんのかすかにわずかに微複雑」なことをしようとするだけで、結構悶絶なのよねこれ。「ほんのかすかにわずかに微複雑」てね、「複数セグメントをカットしたい」てだけよ、そんだけでかなりエラいことになり、メモっとかないとまず思い出せないことになる。

ので。

作為的な入力動画

わけあって Waveform な動画 (ffmpeg で)2。再掲:

便宜上、この入力ファイル名は hnk_charas.mp4 とする。

頭をカットする

-ss は「set the start time offset」なのでこの目的に使えるてことになる。

-ss を「入力ファイル指定の前に記述する」のと「入力ファイル指定の後に記述する」のとで全然振る舞いが違い、前者が猛烈に速い(というか後者が悶絶するほど遅い)のだが、だからといって「いつでも前に書ける」わけではない。今の場合は可能で、たとえば:

1 [me@host: ~]$ #ffmpeg -y -i hnk_charas.mp4 -ss 00:05:52 master_kongo.mp4
2 [me@host: ~]$ ffmpeg -y -ss 00:05:52 -i hnk_charas.mp4 master_kongo.mp4

結果がどうなるか、これについては想像できない人はいないはずだけど一応:

これでいいならこれで十分だが、続くネタに関係してくるので、あえて trim, atrim を使った別解:

コマンドラインに書ききれないのでシェルスクリプトにしてるだけ
1 #! /bin/sh
2 # 00:05:52 -> 352[seconds]
3 ffmpeg -y -i hnk_charas.mp4 -filter_complex \
4 "[0:v]trim=start=352,setpts=PTS-STARTPTS[v];
5  [0:a]atrim=start=352,asetpts=PTS-STARTPTS[a]
6 " -map '[v]' -map '[a]' master_kongo2.mp4

まったく同じ結果を得られ、「面倒」なので何が言いたいんだ、てことになるかと思うが、読み進めていけばわかる。

頭とお尻をカットする

-ss-to を使うのが簡単なのだが、古い ffmpeg では -to が使えないので、この場合は -t を使う。-t-to は無論目的が違い、前者は duration を与えるものであり、-to は特定時刻を与える。

-ss-i の前に置けたように、-to も前置出来る:

1 [me@host: ~]$ ffmpeg -y -ss 00:00:43 -to 00:01:10 -i hnk_charas.mp4 cinnabar.mp4

-to を使えない場合は、代わりに -t で duration を与える:

1 [me@host: ~]$ ffmpeg -y -ss 00:00:43 -t 27 -i hnk_charas.mp4 cinnabar.mp4
2 [me@host: ~]$ #
3 [me@host: ~]$ #またはこれでもいい
4 [me@host: ~]$ ffmpeg -y -ss 00:00:43 -t 00:00:27 -i hnk_charas.mp4 cinnabar3.mp4

頭だけカットの際と同じく trim, atrim を使った別解:

コマンドラインに書ききれないのでシェルスクリプトにしてるだけ
1 #! /bin/sh
2 # 00:00:43 -> 43[seconds]
3 # 00:01:10 -> 70[seconds]
4 ffmpeg -y -i hnk_charas.mp4 -filter_complex \
5 "[0:v]trim=start=43:end=70,setpts=PTS-STARTPTS[v];
6  [0:a]atrim=start=43:end=70,asetpts=PTS-STARTPTS[a]
7 " -map '[v]' -map '[a]' cinnabar4.mp4

時間指定で分割

単純に 30分ごとに分ける、みたいなのではなくて、今回の入力の「Rutile」部分(00:02:01~00:02:14)、「Antarcticite」部分(00:04:37~00:04:51)を抜き出す、というものにしてみる。(「単純に 30分ごとに分ける」みたいなのはこれの単に単純なヤツ。)

すんげーむずかしいのかと思いきや実はこれそのものは全然そうではなくて、実に簡単だったりする:

コマンドラインに書ききれないのでシェルスクリプトにしてるだけ
1 #! /bin/sh
2 ffmpeg -y -i hnk_charas.mp4 \
3   -ss 00:02:01 -to 00:02:14 rutile.mp4 \
4   -ss 00:04:37 -to 00:04:51 antarcticite.mp4

なお、(今使ってる入力の「Master Kongo (Kongou-sensei)」のように)ある開始時刻から末尾まで、であれば -to (あるいは -t) は省略出来る。たとえば:

コマンドラインに書ききれないのでシェルスクリプトにしてるだけ
1 #! /bin/sh
2 ffmpeg -y -i hnk_charas.mp4 \
3   -ss 00:02:01 -to 00:02:14 rutile.mp4 \
4   -ss 00:04:37 -to 00:04:51 antarcticite.mp4 \
5   -ss 00:05:52              master_kongo.mp4

部分カット

直接的に「部分カット」する手段はなさげ。なので基本的に「特定部分抽出」→「結合」が解となる、はず。

「特定部分抽出」→「結合」を各々別のタスクとしてやればまさに以前やったヤツと同じ解。つまり一つ前の結果を入力として concat で結合する。

ただ、「特定部分抽出」→「結合」を各々別のタスクとしてやると、中間ファイルが大量に出来てしまうことになったり、あるいはその中間ファイルの管理が面倒だったりとか色々鬱陶しいわけで、なんとか一回の処理で済ませられませんかいな、てことなわけだ。答えはここにある(このコメントにも注意)が、これをわかりやすい入力を使って自分でやってみようつー話。

「頭をカット」での別解の意味はこれでわかるであろう:

コマンドラインに書ききれないのでシェルスクリプトにしてるだけ
 1 #! /bin/sh
 2 #  -ss 00:02:01 -to 00:02:14 rutile       -> from 121 to 134
 3 #  -ss 00:04:37 -to 00:04:51 antarcticite -> from 277 to 291 
 4 ffmpeg -y -i hnk_charas.mp4 -filter_complex "
 5 [0:v]trim=start=121:end=134,setpts=PTS-STARTPTS[v_1];
 6 [0:a]atrim=start=121:end=134,asetpts=PTS-STARTPTS[a_1];
 7 [0:v]trim=start=277:end=291,setpts=PTS-STARTPTS[v_2];
 8 [0:a]atrim=start=277:end=291,asetpts=PTS-STARTPTS[a_2];
 9 [v_1][a_1][v_2][a_2]concat=a=1[v][a]" \
10   -map '[v]' -map '[a]' rutile_antarcticite.mp4

オマケ: チャプターマークをつける

部分カットだとか分割だとか結合だとかしたいってことは、チャプターマークをつけるなんてのもやりたくなることをやってるつーことだ。

ただチャプターマークを認識出来るプレイヤーを使わないと意味ないんで、チャプターマークを見たければ例えば Windows Media Player Classic だとか VLC Player をお使いなはれ。

ドキュメントはこれ。はっきりいってめちゃくちゃ説明下手、このドキュメントだけで理解出来る人がいたら天才。例えばこういうこと:

ファイル名は hnk_charas_meta.txt だとする
 1 ;FFMETADATA1
 2 
 3 [CHAPTER]
 4 TIMEBASE=1/1
 5 # -ss 00:00:00 -to 00:00:41 phosphophyllite -> from 0 to 41
 6 START=0
 7 END=41
 8 title=phosphophyllite
 9 
10 [CHAPTER]
11 TIMEBASE=1/1
12 # -ss 00:02:01 -to 00:02:14 rutile -> from 121 to 134
13 START=121
14 END=134
15 title=rutile
16 
17 [CHAPTER]
18 TIMEBASE=1/1
19 # -ss 00:04:37 -to 00:04:51 antarcticite -> from 277 to 291 
20 START=277
21 END=291
22 title=antarcticite

(TIMEBASEが理解しにくいと思うが、TIMEBASE=1/1000 と書けば START などの指定がミリ秒、例の TIMEBASE=1/1 なら秒指定。)

これを入力とすれば例えばこうする:

1 [me@host: ~]$ ffmpeg -y \
2 >  -i hnk_charas.mp4 -i hnk_charas_meta.txt -map_metadata 1 \
3 >  -codec copy \
4 >  hnk_charas-with-meta.mp4

hnk_charas.mp4 が「入力0」、hnk_charas_meta.txt が「入力1」なので「-map_metadata 1」てことね。

VLCメディアプレイヤーで見ればこんな感じ:

(すべてのチャプターを切れ目なく入力してないのでヘンに見えるけれど、あるチャプターの末尾が次のチャプターの先頭になるように全部書けば「ヘンな気がする」ことはないので、安心しようとしてみるといい。)

2019-02-19追記: 動画の後ろの方を部分抽出

ここまでに書いてきたことだけでも「出来る」のだけれど、開始時刻が大きくなるほど「悶絶するほど遅い」ことに悩まされるわけだ。たとえば既出の以下2つ:

1 #! /bin/sh
2 ffmpeg -y -i hnk_charas.mp4 \
3   -ss 00:02:01 -to 00:02:14 rutile.mp4 \
4   -ss 00:04:37 -to 00:04:51 antarcticite.mp4 \
5   -ss 00:05:52              master_kongo.mp4
 1 #! /bin/sh
 2 #  -ss 00:02:01 -to 00:02:14 rutile       -> from 121 to 134
 3 #  -ss 00:04:37 -to 00:04:51 antarcticite -> from 277 to 291 
 4 ffmpeg -y -i hnk_charas.mp4 -filter_complex "
 5 [0:v]trim=start=121:end=134,setpts=PTS-STARTPTS[v_1];
 6 [0:a]atrim=start=121:end=134,asetpts=PTS-STARTPTS[a_1];
 7 [0:v]trim=start=277:end=291,setpts=PTS-STARTPTS[v_2];
 8 [0:a]atrim=start=277:end=291,asetpts=PTS-STARTPTS[a_2];
 9 [v_1][a_1][v_2][a_2]concat=a=1[v][a]" \
10   -map '[v]' -map '[a]' rutile_antarcticite.mp4

これらはともに、「どうせ前から順にだから」という理由と、もうひとつは「そう大きな開始時刻指定してないので」という二つの理由により、あまりこのことには気づかない例。けれどもこんなのはもうね…:

1 #! /bin/sh
2 ffmpeg="c:/Program Files/ffmpeg-4.1-win64-shared/bin/ffmpeg"
3 inmov="./#01.mp4"
4 outbase="../#01"
5 ext="mp4"
6 
7 "${ffmpeg}" -y \
8      -i "${inmov}" \
9      -ss 01:32:55.002 -to 01:40:35.003 "${outbase}-10.${ext}"

1時間50分の動画の、1時間33分付近から切り出す…てわけだけれど、これ、本題の処理開始までに途方もない時間がかかる。

ほかの場所でちょろっと「-ss を前置出来て、そうすると高速」と、さらっと説明不足のまま殴り書いたけれど、「そう出来るなら」高速に出来る「はずだ」てわけだ。

「-ss 前置」については大きく2つの説明が必要。

ひとつには無論「-ss 前置」時とそうでない時に ffmpeg は、本質的にどんな振る舞いの違いをしてるのか、のそもそも論。これは「-ss 前置」は「一番近いキーフレーム位置にシーク」、前置でない方はそうではないということ。

キーフレームについて馴染みじゃない人にはなんのこっちゃだと思うけれど、荒っぽい理解は簡単で、要するに「エンコードの基点となるフレーム」だと思えばいい。言い方を変えると、「キーフレーム以外のフレームは不完全な絵で格納されている」。ワタシも詳細は知らないけど、たぶんキーフレームと差分の形で圧縮してるんだと思う。でキーフレームスキャンでないならこれは「指定開始時刻までも通常運行でキーフレーム以外も復号」するので遅い、てことかなと解釈してる。たぶん合ってると思うんだけどね、絶対正しいとは言わない。ただ少なくともエンドユーザにはそう見える。

ゆえに「-ss 前置」方式でキーフレームスキャンする場合、「ワタシ(アナタ)が狙った正確無比なフレームには飛ばない代わりに超絶に高速」てことね。ここまではいい?

で、「だとするならば」の発想。「最初に荒っぽくキーフレームスキャンしておいて、改めて前置でない -ss しりゃぁ良くね?」てことだわ。けれども。

問題になるのは「-ss 前置」後にタイムスタンプが再構成されてしまうのかどうか、てこと。つまり果たしてこうなんだろうかと:

これは「不正解」
 1 #! /bin/sh
 2 ffmpeg="c:/Program Files/ffmpeg-4.1-win64-shared/bin/ffmpeg"
 3 inmov="./#01.mp4"
 4 outbase="../#01"
 5 ext="mp4"
 6 
 7 # 前置の方で予め一気にジャンプしておくつもり
 8 "${ffmpeg}" -y \
 9      -ss 01:32:54.002 -i "${inmov}" \
10      -ss 01:32:55.002 -to 01:40:35.003 "${outbase}-10.${ext}"

正解は「「-ss 前置」後にタイムスタンプが再構成され」。なので、意図通りにするにはこうするしかない:

これが「正解」
 1 #! /bin/sh
 2 ffmpeg="c:/Program Files/ffmpeg-4.1-win64-shared/bin/ffmpeg"
 3 inmov="./#01.mp4"
 4 outbase="../#01"
 5 ext="mp4"
 6 
 7 # (1)前置の方で予め一気にジャンプしておく。
 8 # (2)前置でない方は、再構成後のタイムスタンプに基づいて計算しなければならない。
 9 "${ffmpeg}" -y \
10      -ss 01:32:54.002 -i "${inmov}" \
11      -ss 00:00:01.000 -to 00:07:40.00 "${outbase}-10.${ext}"

これでうまくいくかどうかわからんなぁと思ってやってみたらうまくいった、のだが、「うまくいかんかも」の懸念した意味って通じる? 「不正確な時刻にスキャン」と言ったでしょ。なので、今の例で「01:32:54.002」位置が「正確に再構成後に 00:00:00.00 でないと困る」アプローチなわけよ。けどいくつかやってみた結果、どうやら「意図した 01:32:54.002 を 00:00:00.00 にする」として振舞うらしい。(つまり逆の言い方をすれば -ss 01:32:54.002 で切り出し後の動画の先頭が 00:00:00.00 にならないてこと。)

これ、当然この規模の動画を相手にする場合はそれこそ数分単位の時間短縮になる。無論「計算が面倒くさい」わけで、こんなん毎度手打ちするのは大変よね。全体を python スクリプトにしてしまうとかがまぁ理想なんだけれど、シェルスクリプトを書く際のお助けとしてのこんなん:

「_seconds_to_timestr」「_timestr_to_seconds」という二つの名前を持つスクリプト
 1 #! /bin/env python
 2 # -*- coding: utf-8 -*-
 3 ##[2019-03-06 11:00]古いのも履歴として残しとく。「間違ってたので直した」の方も間違ってた。
 4 ##def _ts_to_tss(ts):
 5 ##    hh = int(ts / 3600)
 6 ##    ts -= hh * 3600
 7 ##    mm = int(ts / 60)
 8 ##    ts -= mm * 60
 9 ##    ss = int(ts)
10 ##    ts -= ss
11 ##    # [2019-02-20 10:30]この時刻以前にこれを読んじゃった人、ごめん、
12 ##    #                   思いっきり間違ってた。修正後は「ダサ」いけど一応正しい、ハズ。
13 ##    #return "%02d:%02d:%02d.%02d" % (hh, mm, ss, int(ts*100))
14 ##    return "%02d:%02d:%02d.%s" % (
15 ##        hh, mm, ss, ("%.3f" % ts).partition(".")[2])
16 ##
17 ##
18 ##def _tss_to_ts(tss):
19 ##    import math
20 ##    dp, _, fp = tss.partition(".")
21 ##    dp = map(int, dp.split(":"))
22 ##    dp = int(sum([p * math.pow(60, 2 - i) for i, p in enumerate(dp)]))
23 ##    if fp:
24 ##        return "%s.%s" % (dp, fp)
25 ##    return "%s" % (dp)
26 
27 
28 def _ts_to_tss(ts, frac=3):
29     d, _, f = (("%%.%df" % frac) % ts).partition(".")
30     d = abs(int(d))
31     ss_h = int(d / 3600)
32     d -= ss_h * 3600
33     ss_m = int(d / 60)
34     d -= ss_m * 60
35     ss_s = int(d)
36     return "%s%02d:%02d:%02d.%s" % (
37         "" if ts >= 0 else "-",
38         ss_h, ss_m, ss_s, f)
39 
40 
41 def _tss_to_ts(tss):
42     try:
43         return float(tss)
44     except ValueError:
45         import math
46         dp, _, fp = tss.partition(".")
47         dp = map(int, dp.split(":"))
48         dp = int(sum([p * math.pow(60, 2 - i) for i, p in enumerate(dp)]))
49         if fp:
50             dp = "%s.%s" % (dp, fp)
51         return float(dp)
52 
53 
54 if __name__ == '__main__':
55     import sys
56     import os
57 
58     if os.path.basename(sys.argv[0]) == "_timestr_to_seconds":
59         f1, f2 = _tss_to_ts, lambda s: s
60     else:
61         f1, f2 = _ts_to_tss, float
62     if len(sys.argv) > 1:
63         for s in sys.argv[1:]:
64             print(f1(f2(s.rstrip())))

があるだけでも少しはマシかしらねと思う。

2019-02-21追記: 「真っ黒かつ無音」部分に基づいて分割

動画を「真っ黒かつ無音」部分に基づいて分割な ffmpeg