ffmpeg で動画の途中映像だけ早送り効果…で無駄に苦労

よくやるんだ、こういう「バカ」なこと。

ffmpeg で動画の途中映像だけ早送り効果

やりたかったこと

ゲームのプレイ動画みたいなものを想像してみて欲しい。ビジュアルノベル系のゲームが一番近い。

基本的にアクションに対する SE、人物の会話の音声を除けば、BGM は無限ループしているとする。このような入力動画の、ある特定の時間帯についてだけ「映像だけ早送り」、みたいなことをしたかった。

ほかに似た状況としては、WEB ページをスクリーンキャストで「録画」したような動画で、強調したい部分以外のスクロール部分を早送りしたい、みたいなことである。

いずれにしても、「映像だけ早送りするが音声は元のまま」が目的のもので、そして音声は元のを使いつつも最後はフェードアウトで締めたい、と。まぁそういうこと。

事始め

「基礎的なことを説明しちゃるぜっ」てことではなくて、「猿でも出来ること」、つまりちょっとググると出てくる「簡単だぜっ」の話からまずは。

基礎的なことがわかってようがわかってまいが、動画ファイル全体に対し「スピードアップ」「スピードダウン」する手段はこれだけちゃぁこれだけ:

2倍速の例
1 [me@host: ~]$ ffmpeg -i input.mp4 \
2 > -vf 'setpts=0.5*PTS-STARTPTS' \
3 > -af 'atempo=2' \
4 > out.mp4

映像と音声は逆数の関係で与えなければならないのがウザいちゃぁウザいけれどこれだけ。

注意して欲しいのは、これは最近のハードディスクレコーダなんかが実装してる「倍速再生」とは違うてこと。アレらは倍速でも音声が「高くならない」(つまり周波数が変わらない)けれど、今ここでやってるのはむしろ昔のカセットデッキで早送りするのと一緒で、高速にすればするほど高音になる。その「高等な」早送りの理屈はワタシもわかるけどやり方は知らないし、今ここでは考えない。

2020-11-21: 実は随分前からここの誤った記述に気付いてたがあんま読む人おらんだろうと思って放置してたが、どうも結構読む人いるみたいなので今更修正する。すまんガセである。atempo はキーを変えずにテンポを変えてくれる。ここいらへんの正しい情報については、ワタシ自身もここに書いたので、そっちをみてほしい。

なのでワタシ的本題は、この「基礎」と trim、concat を駆使すれば完成しろう、と。

ワタシ的本題の、ある意味失敗作

「ある意味」というのがミソで。そこいらの事情については後述するとして、出来てみたぜっ、やったね:

例によって UNIX シェルスクリプト
 1 #! /bin/sh
 2 ifn_b="`basename \"${1}\"` .mp4"
 3 ifn="${ifn_b}".mp4
 4 
 5 #
 6 fac=${2}
 7 beg=${3}
 8 end=${4}
 9 
10 #
11 dur=`ffprobe -hide_banner "${ifn}" 2>&1 | grep Duration | sed 's@^.*tion: \(.*\), st.*$@\1@'`
12 cat << __END__ > ___t.py
13 import sys, re
14 m = re.match(r'^(\d+):(\d+):(\d+)\.(\d+)$', sys.argv[1])
15 ri = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3))
16 print("%d.%s" % (ri, m.group(4)))
17 __END__
18 dur=`python ___t.py ${dur}`
19 dur=`python -c "print('%.1f' % (${dur}-(1-${fac})*(${end}-${beg})))"`
20 fod=2
21 fos=`python -c "print('%.1f' % (${dur}-${fod}))"`
22 
23 #
24 trap 'rm -f ___t.py ; for i in ${ifn_b}_tmp* ; do rm -fv "$i" ; done' 0 1 2 3 15
25 #
26 ffmpeg -y -i "${ifn}" -i _"${ifn}" -filter_complex "
27 [0:v]trim=0.0000:${beg},setpts=PTS-STARTPTS[v1];
28 [0:v]trim=${beg}:${end},setpts=PTS-STARTPTS,setpts=${fac}*PTS-STARTPTS[v2];
29 [0:v]trim=${end},setpts=PTS-STARTPTS[v3];
30 [v1][v2][v3]concat=n=3:a=0:v=1[v]
31 ;
32 [1:a]
33 atrim=0:${dur},asetpts=PTS-STARTPTS
34 ,afade=t=out:st=${fos}:d=${fod}
35 [a]
36 " -map '[v]' -map '[a]' "${ifn_b}"_tmp.mp4 && \
37   mv -fv "${ifn}" "${ifn_b}"_orig.mp4 && mv -fv "${ifn_b}"_tmp.mp4 "${ifn}"

python を呼び出したり色々ごにょごにょしとるけれど、本質的にワタシが ここまでで「苦労」したのはひとつだけ:

  • setpts=PTS-STARTPTS は trim の後に必ず必要である

以後「苦労だと思っていたこと」の話が続く。

はにゃぁ

いや、さっきのでもね、「いいときはいい」わけだよ。が、ワタシの場合はダメだった。

「オカシイ」と感じ始めたのは、5~6箇所「早送り」させてみた後から。音がヘンなのである。ガサゴソっと歪んだような音になってしまう。そして無駄な迷走が始まったのであった…。

どういう試行錯誤を試みたのかについては省略する。とにかく「バカ」なことをたくさんしました、と言っとく。

何が起こっていたのかをやっと理解出来たのは…「オカシイのは音だけじゃなくてさぁ」に気付いてから。そう、映像も「劣化」しているのであった。

これがわかってからは、もう何が起こったのかはっきり理解した。そう、「エンコードで劣化」してただけのこと。具体的には音声は mp4 ならデフォルトだと aac なので、aac の「デフォルト」によって当然エンコーダは「よきにはからって圧縮(不可逆変換)」しようとする、ので、数回変換かけたらよほど耳が悪い人でもすぐにわかるほどに劣化してしまう。映像も同じ。

ので

つまり「何箇所もコレをやりたい」場合に、「何度も変換」を要するアプローチが既にアウト、ということ。たったそれだけの話なのであった。

ゆえ、考えられる策は2つ。一つは「(出来るだけ)ロスレスな変換を考える」、もう一つは「チマチマやらんと一気にやれやぁ」。前者もあると思うけれど、まぁふつーは後者が「嬉しい」だろう。無論「どっちもやれや」が正解だけれど、「気にならない劣化なら」後者だけでもよかんべ、と。

こうなっちゃうともう trim 結果につけるラベルと concat に渡す入力を「手で」メンテするなんてムチャな相談なので、やはり python スクリプトにしてしまうしかなかろ、と。なので、json 形式で指示を与えてシェルスクリプトを生成するのがよかろ:

こんな json を受け取る
 1 {
 2     "orig": "_orig.mp4",
 3     "target_file": "target.mp4",
 4     "target_time": [
 5         [82, 104, 0.6],
 6         [107, 113, 0.25],
 7         [116, 120, 0.25],
 8         [121, 142, 0.6],
 9         [143, 172, 0.25]
10     ],
11     "audio_fadeout_dur": 2
12 }
こんな python スクリプト
 1 #! /c/Python35/python
 2 # -*- coding: utf-8 -*-
 3 import sys
 4 import re
 5 import json
 6 
 7 # align_videos_by_soundtrack は https://github.com/jeorgen/align-videos-by-sound/ ね。
 8 # 元動画から duration を取る必要があるわけだけれど、面倒だったのでこれを再利用した。
 9 # (どーせワタシが作ったもんであるし。)
10 from align_videos_by_soundtrack import communicate
11 
12 #{
13 #    "orig": "base.mp4",
14 #    "target_file": "aaa.mp4",
15 #    "target_time": [
16 #        [10, 15, 0.5],
17 #        [20, 25, 0.25],
18 #        ...
19 #    ],
20 #    "audio_fadeout_dur": 2
21 #}
22 
23 
24 if __name__ == '__main__':
25     defs = json.loads(open(sys.argv[1]).read())
26     #
27     dur_orig = communicate.get_media_info(defs["target_file"])["duration"]
28     dur = dur_orig
29     for beg, end, fac in defs["target_time"]:
30         dur -= (1 - fac) * (end - beg)
31     fadeout_st = dur - defs["audio_fadeout_dur"]
32 
33     # video
34     trims = []
35     last_end = 0.0
36     for beg, end, fac in defs["target_time"]:
37         if last_end < beg:
38             trims.append(
39                 "[0:v]trim={last_end}:{beg},setpts=PTS-STARTPTS[v{idx}]".format(
40                     last_end=last_end, beg=beg, idx=len(trims)))
41         trims.append(
42             "[0:v]trim={beg}:{end},setpts=PTS-STARTPTS,setpts={fac}*PTS-STARTPTS[v{idx}]".format(
43                 beg=beg, end=end, fac=fac, idx=len(trims)))
44         last_end = end
45     trims.append(
46         "[0:v]trim={last_end},setpts=PTS-STARTPTS[v{idx}]".format(
47             last_end=last_end, fac=fac, idx=len(trims)))
48 
49     # audio
50     aud = "[1:a]atrim=0.0:{dur},asetpts=PTS-STARTPTS,afade=t=out:st={fos}:d={fod}[a]".format(
51         dur="%.4f" % dur,
52         fos="%.4f" % (dur - defs["audio_fadeout_dur"]),
53         fod=defs["audio_fadeout_dur"])
54 
55     # result script
56     print(r"""#! /bin/sh
57 
58 #
59 tf="{target_file}"
60 bn="`basename \"${{tf}}\"`"
61 
62 #
63 ffmpeg -y -i "${{tf}}" -i "{orig}" -filter_complex "
64 {vtrims}
65 ;
66 {concat}
67 ;
68 {aud}
69 " -map '[v]' -map '[a]' /tmp/"${{bn}}" && \
70   mv -fv "${{tf}}" "${{tf}}".orig && mv -fv /tmp/"${{bn}}" "${{tf}}"
71 """.format(
72             target_file=defs["target_file"],
73             orig=defs["orig"],
74             vtrims=";\n".join(trims),
75             concat="{labs}concat=n={num}:a=0:v=1[v]".format(
76                 labs="".join(["[v%d]" % i for i in range(len(trims))]),
77                 num=len(trims)),
78             aud=aud))

処理するとたとえばこんなシェルスクリプトを吐き出す:

 1 #! /bin/sh
 2 
 3 #
 4 tf="aaa.mp4"
 5 bn="`basename \"${tf}\"`"
 6 
 7 #
 8 ffmpeg -y -i "${tf}" -i "_aaa.mp4" -filter_complex "
 9 [0:v]trim=0.0:82,setpts=PTS-STARTPTS[v0];
10 [0:v]trim=82:104,setpts=PTS-STARTPTS,setpts=0.6*PTS-STARTPTS[v1];
11 [0:v]trim=104:107,setpts=PTS-STARTPTS[v2];
12 [0:v]trim=107:113,setpts=PTS-STARTPTS,setpts=0.25*PTS-STARTPTS[v3];
13 [0:v]trim=113:116,setpts=PTS-STARTPTS[v4];
14 [0:v]trim=116:120,setpts=PTS-STARTPTS,setpts=0.25*PTS-STARTPTS[v5];
15 [0:v]trim=120:121,setpts=PTS-STARTPTS[v6];
16 [0:v]trim=121:142,setpts=PTS-STARTPTS,setpts=0.6*PTS-STARTPTS[v7];
17 [0:v]trim=142:143,setpts=PTS-STARTPTS[v8];
18 [0:v]trim=143:172,setpts=PTS-STARTPTS,setpts=0.25*PTS-STARTPTS[v9];
19 [0:v]trim=172:177,setpts=PTS-STARTPTS[v10];
20 [0:v]trim=177:346,setpts=PTS-STARTPTS,setpts=0.25*PTS-STARTPTS[v11];
21 [0:v]trim=346:350,setpts=PTS-STARTPTS[v12];
22 [0:v]trim=350:419,setpts=PTS-STARTPTS,setpts=0.6*PTS-STARTPTS[v13];
23 [0:v]trim=419:420,setpts=PTS-STARTPTS[v14];
24 [0:v]trim=420:425,setpts=PTS-STARTPTS,setpts=0.25*PTS-STARTPTS[v15];
25 [0:v]trim=425:442,setpts=PTS-STARTPTS,setpts=0.6*PTS-STARTPTS[v16];
26 [0:v]trim=442:443,setpts=PTS-STARTPTS[v17];
27 [0:v]trim=443:464,setpts=PTS-STARTPTS,setpts=0.25*PTS-STARTPTS[v18];
28 [0:v]trim=464:466,setpts=PTS-STARTPTS[v19];
29 [0:v]trim=466:488,setpts=PTS-STARTPTS,setpts=0.6*PTS-STARTPTS[v20];
30 [0:v]trim=488:490,setpts=PTS-STARTPTS[v21];
31 [0:v]trim=490:502,setpts=PTS-STARTPTS,setpts=0.25*PTS-STARTPTS[v22];
32 [0:v]trim=502,setpts=PTS-STARTPTS[v23]
33 ;
34 [v0][v1][v2][v3][v4][v5][v6][v7][v8][v9][v10][v11][v12][v13][v14][v15][v16][v17][v18][v19][v20][v21][v22][v23]concat=n=24:a=0:v=1[v]
35 ;
36 [1:a]atrim=0.0:14.2700,asetpts=PTS-STARTPTS,afade=t=out:st=12.2700:d=2[a]
37 " -map '[v]' -map '[a]' /tmp/"${bn}" && \
38   mv -fv "${tf}" "${tf}".orig && mv -fv /tmp/"${bn}" "${tf}"

なんというか最初からサボらずに python スクリプトから始めてればやらんで済んだ苦労、だったわけよね。こんくらいのもん、一時間もかからず作れるんだから。(事実30分くらいで作った。)