ffmpegを使って動画を切り取る(trim:トリムする)ときに、キーフレーム(keyframe:Iフレーム)を使う方法をメモしておく。

実行環境
MacOSX 10.8.5
ffmpeg ver 2.4.2
bash ver 3.2.53

今回使用した動画の形式
コンテナ mp4
動画コーデック h264
音声コーデック aac

何故キーフレームを選択するかというと、キーフレーム単位で動画を切り取ると動画の再エンコードが必要ないので、元の画像・音声を劣化せずに切り取れるからである(詳細は"動画 GOP"で検索)。またエンコードが無いため処理が一瞬で終わる。

逆にキーフレームを指定しない場合は再エンコードが発生して処理に時間がかかる。キーフレーム以外の任意の位置から切り取りを開始したい場合は、必ず後者の処理を行うことになる。

1.切り取りに使うffmpegのコマンド


ffmpeg -ss "開始時刻" -i input.mp4 -ss 0 -t "切り取り時間" -c:v copy -c:a copy -async 1 -strict -2 output.mp4

この"開始時刻"と"切り取り時間"に、キーフレームの時刻(時間)を設定する。

最初の「-ss "開始時刻"」でffmpegに切り取りの開始位置をシークさせ、2番目の「-ss 0」で開始時刻から切り取りを実行する。

「-i」の前に「-ss "開始時刻"」をつけるだけで十分な気もするが、これだとffmpegが開始時刻を正しくシークしてくれない。結果、切り取った映像の最初の1〜4秒間くらいがグレー画面になったり、フレームが飛ばされたりと酷い映像が出来上がる。なので「-i」の後ろにもう一度「-ss 0」と設定する事により、シークした時刻から正確に切り取り開始するようになる。

どうも無劣化で切り取る場合(copyを使った場合)に発生するffmpegの仕様(バグ?)のようで、上記で述べた再エンコードを行う場合はこの事象は発生しない。

尚、このコマンドで「-t」の代わりに「-to "終了時刻"」を使おうとすると、「-to」が無視されて「-t」に自動的に変換される。どうやら最初に「-ss」を持ってきた場合に限り「-to」が無視されるようだ。

2.キーフレームを調べる


まず対象の動画のキーフレームを調べるために、以下のコマンドを実行する。

ffprobe -show_frames -pretty input.mp4

実行すると、動画と音声のフレーム単位の詳細な情報が出力される。

[FRAME]  ← 1フレームの動画情報
media_type=video
key_frame=0
pkt_pts=864064
pkt_pts_time=0:00:54.004000
pkt_dts=864064
pkt_dts_time=0:00:54.004000
best_effort_timestamp=864064
best_effort_timestamp_time=0:00:54.004000
pkt_duration=533
pkt_duration_time=0:00:00.033313
pkt_pos=3041277
pkt_size=358
width=604
height=340
pix_fmt=yuv420p
sample_aspect_ratio=1360:1359
pict_type=B
coded_picture_number=1617
display_picture_number=0
interlaced_frame=0
top_field_first=0
repeat_pict=0
[/FRAME]
[FRAME]  ← 1フレームの音声情報
media_type=audio
key_frame=1
pkt_pts=1193982
pkt_pts_time=0:00:54.148844
pkt_dts=1193982
pkt_dts_time=0:00:54.148844
best_effort_timestamp=1193982
best_effort_timestamp_time=0:00:54.148844
pkt_duration=1024
pkt_duration_time=0:00:00.046440
pkt_pos=3047553
pkt_size=355
sample_fmt=fltp
nb_samples=1024
channels=2
channel_layout=stereo
[/FRAME]

今回必要なのは動画情報のみ(media_type=video)なので、オプションに「-select_streams -v」を追加して動画のフレーム情報のみ抽出する。

ffprobe -show_frames -select_streams -v -pretty input.mp4

[FRAME]
media_type=video
key_frame=1
pkt_pts=1772176
pkt_pts_time=0:01:50.761000
pkt_dts=1772176
pkt_dts_time=0:01:50.761000
best_effort_timestamp=1772176
best_effort_timestamp_time=0:01:50.761000
pkt_duration=533
pkt_duration_time=0:00:00.033313
pkt_pos=5994965
pkt_size=24411
width=604
height=340
pix_fmt=yuv420p
sample_aspect_ratio=1360:1359
pict_type=I
coded_picture_number=3316
display_picture_number=0
interlaced_frame=0
top_field_first=0
repeat_pict=0
[/FRAME]
[FRAME]
media_type=video
key_frame=0
pkt_pts=1772704
pkt_pts_time=0:01:50.794000
pkt_dts=1772704
pkt_dts_time=0:01:50.794000
best_effort_timestamp=1772704
best_effort_timestamp_time=0:01:50.794000
pkt_duration=533
pkt_duration_time=0:00:00.033313
pkt_pos=6025150
pkt_size=535
width=604
height=340
pix_fmt=yuv420p
sample_aspect_ratio=1360:1359
pict_type=B
coded_picture_number=3319
display_picture_number=0
interlaced_frame=0
top_field_first=0
repeat_pict=0
[/FRAME]

この結果の中で「key_frame=1」となっているのがキーフレームのことである。このフレームの「pict_type」をみると、このフレームが「pict_type=I」(Iフレーム)であることがわかる。

ffmpegで必要なのはこのキーフレームの時刻であるため、ここから「pkt_pts_time」を抽出する。

ffprobe -show_frames -select_streams -v -pretty input.mp4 | grep "key_frame=1" -A 3 | grep -w pkt_pts_time | cut -d= -f2 | sed -e s/000\$// > temp.txt

「grep "key_frame=1" -A 3」とは、ffprobeの結果から「key_frame=1」を抽出し、さらにその行を含む3行を抽出する。
次のgrepで「pkt_pts_time」の行だけを抽出し、cutで"="より後ろの時刻を取り出し、最後にsedで時刻の余分なミリ秒(後3桁"000")を削除してtemp.txtに書き出す。

尚、元動画の時間が長いほど、この処理に時間がかかるので暫く待つ(1分程度)

3.切り取り時間を求める


ffmpegでもう一つ必要なのは"切り取り時間"なので、このtemp.txtファイルの中から開始時刻と終了時刻を選択し、その差分(切り取り時間)をbashで求める。
#!/bin/bash

ts_get_msec()
{
	read -r h m s ms <<< $(echo $1 | tr '.:' ' ')
#	echo $(((h*60*60*1000)+(m*60*1000)+(s*1000)+ms))
	echo $(((10#${h}*60*60*1000)+(10#${m}*60*1000)+(10#${s}*1000)+10#${ms}))
}

ts_get_time()
{
	hour=$(($1/(60*60*1000)))
	min=$((($1%(60*60*1000))/(60*1000)))
	sec=$((($1%(60*1000))/1000))
	ms=$((($1%(60*1000))%1000))

	min=0$min
	sec=0$sec
	ms=0$ms

	echo "${hour}:${min:(-2)}:${sec:(-2)}.${ms:(-3)}"
}

start_pts=開始時刻 (例 0:01:24.333)
start_msec=$(ts_get_msec $start_pts)

end_pts=終了時刻 (例 0:08:45.338)
end_msec=$(ts_get_msec $end_pts)

DIFF=$((end_msec-start_msec))
duration_ts=$(ts_get_time $DIFF)

echo $duration_ts


bashで時刻の差分を計算する場合「date -d"時刻" +%s」コマンドを使って一旦ミリ秒に変換するのが簡単であるが、私の実行環境のMacはBSD UNIXをベースに作られているため「date」コマンドの「-d」オプションが使用できない。正直、Unixの〜系はほとんど意識したことがなかったので、今回はドツボにハマってかなり時間を食ってしまった。

そこでgoogle先生から「date」コマンドに頼らない自作の関数を教えてもらい、その内部でミリ秒に変換する処理を行う。(上記 ts_get_msec関数)