ffmpeg で静止画から動画 Second Edition Version 2.31

よくあるしつもんとかいとう?

かな~り久々の投稿も例によって ffmpeg ネタ。

ffmpeg で静止画から動画

モチベーションと良くある回答

わたしは「魔改造皆無の Windows 7」ユーザのままなので、「Movie Maker じゃやりたいことでけんのよねぇ」となると毎度ハタと困ってしまう。

ワタシはこういうことがしたかったのだ:

  1. 音声ファイル(例えば英語教材)を持っている。
  2. その音声ファイルには背景動画がないかもしくは気に入らない。
  3. ので収集済みの手持ちの関連静止画たちを背景動画に仕立て上げたい。

Windows 7 に付属の Movie Maker でも「あたかもこのニーズに耐える」かのように見えて、これが全然ダメ。まず Movie Maker には明確な主従があって、「静止画たちで作る動画に BGM をつける」ようにしか作られてない。つまり「音声に背景動画をつける」ように使うのは不可能もしくは難儀だ。これは実際に使ってみればわかる。のみならず、Movie Maker ではどうやら各入力静止画を動画で何秒間使うかの制御が出来ない…、みたいだ。少なくとも Windows 7 に付属のヤツではこれを見つけられてない。

となった場合に何を考えるかと言えば、ひとつには「もっと高機能なや~つ」を探し回る(そして疲れ果てる)か、例によって「めんどいけど ffmpeg かぁ」と諦めるかの二択だ。前者も興味はあるが今回は後者で。やりたいことの性質上ちょっとは高等な GUI が向いているのが明らか、だけれど、探し回って疲れるのも今回は体力もないし。

なお、PyAV という手もあるが今回は剥き身の ffmpeg で。

てなわけでさて、と、ffmpeg image to video duration みたいな検索をかけると「いつもの」質問と回答がわんさかヒットする:

連番で命名された静止画たちを動画に仕立て上げるなるなり
1 [me@host: ~]$ ffmpeg -r 1/5 -start_number 2 -i img%03d.png -c:v libx264 -r 30 -pix_fmt yuv420p out.mp4
静止画一枚を任意病数の動画に仕立て上げるなるなり
1 [me@host: ~]$ ffmpeg -loop 1 -i image.png -c:v libx264 -t 15 -pix_fmt yuv420p -vf scale=320:240 out.mp4

これでやりたいことが出来ないってことではないのだけれど、少なくとも「そのものズバリ」でないことはわかるであろう。こういう「大量の静止画を一気にバカちょんで動画に」したいんではなくて、「(まがりなりにも)ちゃんとした編集」をしたいのだ。つまりは最低でも「入力静止画の各々の duration は自由に任意に」制御したいわけである。

なお、いつものように実験結果をお見せするが、入力に使ったイメージは pexels の CC0 ライセンスのものたち。

出来てみたファーストシーズン

要するに filter_complex を地道にごにょればいいんでしょ、って発想でスタートしてみるわけだ。なので「静止画を入力としてこれを任意フレーム群として複製する術があればあるだろうか」と考えるわけなのだが、やぱ~り loop しかなさげ。そうなのか、そうなのだろうなぁ。

たとえばこんな:

いつものごとく bash シェルスクリプトの態で
 1 #! /bin/sh
 2 
 3 #
 4 # simple
 5 #
 6 ffmpeg -y \
 7     -i cat1.jpg \
 8     -i cat2.jpg \
 9     -i cat3.jpg \
10     -c:v libx264 \
11     -pix_fmt yuv420p \
12     -r 25 \
13     -filter_complex "
14 [0]scale=960:720,setsar=1,loop=loop=25:size=2[v1];
15 [1]scale=960:720,setsar=1,loop=loop=50:size=2[v2];
16 [2]scale=960:720,setsar=1,loop=loop=75:size=2[v3];
17 
18 [v1][v2][v3]concat=n=3[v]" \
19 -map '[v]' cat1.mp4
20 
21 
22 #
23 # with fading in/out
24 #
25 ffmpeg -y \
26     -i cat1.jpg \
27     -i cat2.jpg \
28     -i cat3.jpg \
29     -c:v libx264 \
30     -pix_fmt yuv420p \
31     -r 25 \
32     -filter_complex "
33 [0]scale=960:720,setsar=1,loop=loop=25:size=2,fade=in:0:5,fade=out:20:5[v1];
34 [1]scale=960:720,setsar=1,loop=loop=50:size=2,fade=in:0:5,fade=out:45:5[v2];
35 [2]scale=960:720,setsar=1,loop=loop=75:size=2,fade=in:0:5,fade=out:75:5[v3];
36 
37 [v1][v2][v3]concat=n=3[v]" \
38 -map '[v]' cat2.mp4

さらっと書いてるが、「苦労時間」の 99.8% がこの「loop=loop=75:size=2」であった。正確に覚えてないが体感では数時間これだけでうんうん唸ってた。「説明不足」だ、いつものように:

size
Set maximal size in number of frames. Default is 0.

ナニイッテンダ?

出来てみた実例から「無理やり解釈」したこれの意味はたぶんこう:

  1. loop は静止画用ではなくて。
  2. なので「このフレームからこのフレームまでを繰り返すなるなり」の意味なろう
  3. つまりは繰り返しの末端フレームを指すだのか?
  4. なればなんで size=1 でないのや。

なんだかよくわからんけれど、「一枚絵=フレーム一枚」「このフレーム一枚とは開始フレーム0に相当しろう」てことなら「末端」を指示するのに 0 か 1 なら解せるがなぜに 2 か。ともあれ「解せる最大 + 1」を渡せば期待通りになる、らしい。なんなんだこれは。

size がなんだか妙で 1 多く渡さなければならないことを受け容れるならば loop の残りを「解釈」することは出来ろう。「1×75」であれば、今フレームレートを 25 としているので、これは3秒。動画入力で使いたい場合は「3フレームを10回繰り返せ」たければどうすればいいかはすぐわかろう。

fade も微妙に鬱陶しいが説明も鬱陶しいので 公式ドキュメント参照。なんにせよ今の場合 loop での指定に引きずられるので、違うことがしたくなった場合に編集箇所が多くてめげてしまう。

ともあれ、上の例の結果は、これだぁ:

出来てみたセカンドシーズン(アスペクト比、音声)

話を先に進める前に、ごまかした部分2点。

一つ目が「アスペクト比がバラバラの入力だよね、ふつー」。二つ目が、モチベーションに書いたこと、「音声ファイルを手持ちで背景動画をつけたいんや」な件。

前者:

詳細は https://ffmpeg.org/ffmpeg-filters.html#scale-1
 1 #! /bin/sh
 2 
 3 #
 4 # simple
 5 #
 6 ffmpeg -y \
 7     -i cat4.jpg \
 8     -i cat2.jpg \
 9     -i cat3.jpg \
10     -c:v libx264 \
11     -pix_fmt yuv420p \
12     -r 25 \
13     -filter_complex "
14 [0]scale=w=960:h=720:force_original_aspect_ratio=1,
15 pad=960:720:(ow-iw)/2:(oh-ih)/2,
16 setsar=1,
17 loop=loop=25:size=2[v1];
18 [1]scale=w=960:h=720:force_original_aspect_ratio=1,
19 pad=960:720:(ow-iw)/2:(oh-ih)/2,
20 setsar=1,
21 loop=loop=50:size=2[v2];
22 [2]scale=w=960:h=720:force_original_aspect_ratio=1,
23 pad=960:720:(ow-iw)/2:(oh-ih)/2,
24 setsar=1,
25 loop=loop=75:size=2[v3];
26 
27 [v1][v2][v3]concat=n=3[v]" \
28 -map '[v]' cat1.mp4
29 
30 
31 #
32 # with fading in/out
33 #
34 ffmpeg -y \
35     -i cat4.jpg \
36     -i cat2.jpg \
37     -i cat3.jpg \
38     -c:v libx264 \
39     -pix_fmt yuv420p \
40     -r 25 \
41     -filter_complex "
42 [0]scale=w=960:h=720:force_original_aspect_ratio=1,
43 pad=960:720:(ow-iw)/2:(oh-ih)/2,
44 setsar=1,
45 loop=loop=25:size=2,fade=in:0:5,fade=out:20:5[v1];
46 [1]scale=w=960:h=720:force_original_aspect_ratio=1,
47 pad=960:720:(ow-iw)/2:(oh-ih)/2,
48 setsar=1,
49 loop=loop=50:size=2,fade=in:0:5,fade=out:45:5[v2];
50 [2]scale=w=960:h=720:force_original_aspect_ratio=1,
51 pad=960:720:(ow-iw)/2:(oh-ih)/2,
52 setsar=1,
53 loop=loop=75:size=2,fade=in:0:5,fade=out:75:5[v3];
54 
55 [v1][v2][v3]concat=n=3[v]" \
56 -map '[v]' cat2.mp4

音声については例えば4つ目の入力(インデクスは3)として sample_audio.mp4 を使うとして:

 1 #! /bin/sh
 2 
 3 #
 4 # simple
 5 #
 6 ffmpeg -y \
 7     -i cat4.jpg \
 8     -i cat2.jpg \
 9     -i cat3.jpg \
10     -i sample_audio.mp4 \
11     -c:v libx264 \
12     -pix_fmt yuv420p \
13     -r 25 \
14     -filter_complex "
15 [0]scale=w=960:h=720:force_original_aspect_ratio=1,
16 pad=960:720:(ow-iw)/2:(oh-ih)/2,
17 setsar=1,
18 loop=loop=25:size=2[v1];
19 [1]scale=w=960:h=720:force_original_aspect_ratio=1,
20 pad=960:720:(ow-iw)/2:(oh-ih)/2,
21 setsar=1,
22 loop=loop=50:size=2[v2];
23 [2]scale=w=960:h=720:force_original_aspect_ratio=1,
24 pad=960:720:(ow-iw)/2:(oh-ih)/2,
25 setsar=1,
26 loop=loop=75:size=2[v3];
27 
28 [v1][v2][v3]concat=n=3[v]" \
29 -map '[v]' -map '3:a' cat1.mp4
30 
31 
32 #
33 # with fading in/out
34 #
35 ffmpeg -y \
36     -i cat4.jpg \
37     -i cat2.jpg \
38     -i cat3.jpg \
39     -i sample_audio.mp4 \
40     -c:v libx264 \
41     -pix_fmt yuv420p \
42     -r 25 \
43     -filter_complex "
44 [0]scale=w=960:h=720:force_original_aspect_ratio=1,
45 pad=960:720:(ow-iw)/2:(oh-ih)/2,
46 setsar=1,
47 loop=loop=25:size=2,fade=in:0:5,fade=out:20:5[v1];
48 [1]scale=w=960:h=720:force_original_aspect_ratio=1,
49 pad=960:720:(ow-iw)/2:(oh-ih)/2,
50 setsar=1,
51 loop=loop=50:size=2,fade=in:0:5,fade=out:45:5[v2];
52 [2]scale=w=960:h=720:force_original_aspect_ratio=1,
53 pad=960:720:(ow-iw)/2:(oh-ih)/2,
54 setsar=1,
55 loop=loop=75:size=2,fade=in:0:5,fade=out:75:5[v3];
56 
57 [v1][v2][v3]concat=n=3[v]" \
58 -map '[v]' -map '3:a' cat2.mp4

このままだと音声ファイルが最も長いとすれば生成結果は最長に合わせられることになる。これが目的の振る舞いかどうかはやりたいことによるだろう。このままでも q をタイプすれば止められるので、少しずつ完成させていくスタイルで作業していくつもりなら、このままでもいいかもしれない。そうではなく映像の方(短い方)に合わせたいなら、コマンドラインオプションの -shortest を使えばいい:

 1 #! /bin/sh
 2 
 3 #
 4 # simple
 5 #
 6 ffmpeg -y \
 7     -i cat4.jpg \
 8     -i cat2.jpg \
 9     -i cat3.jpg \
10     -i sample_audio.mp4 \
11     -c:v libx264 \
12     -pix_fmt yuv420p \
13     -r 25 \
14     -filter_complex "
15 [0]scale=w=960:h=720:force_original_aspect_ratio=1,
16 pad=960:720:(ow-iw)/2:(oh-ih)/2,
17 setsar=1,
18 loop=loop=25:size=2[v1];
19 [1]scale=w=960:h=720:force_original_aspect_ratio=1,
20 pad=960:720:(ow-iw)/2:(oh-ih)/2,
21 setsar=1,
22 loop=loop=50:size=2[v2];
23 [2]scale=w=960:h=720:force_original_aspect_ratio=1,
24 pad=960:720:(ow-iw)/2:(oh-ih)/2,
25 setsar=1,
26 loop=loop=75:size=2[v3];
27 
28 [v1][v2][v3]concat=n=3[v]" \
29 -map '[v]' -map '3:a' -shortest cat1.mp4
30 
31 
32 #
33 # with fading in/out
34 #
35 ffmpeg -y \
36     -i cat4.jpg \
37     -i cat2.jpg \
38     -i cat3.jpg \
39     -i sample_audio.mp4 \
40     -c:v libx264 \
41     -pix_fmt yuv420p \
42     -r 25 \
43     -filter_complex "
44 [0]scale=w=960:h=720:force_original_aspect_ratio=1,
45 pad=960:720:(ow-iw)/2:(oh-ih)/2,
46 setsar=1,
47 loop=loop=25:size=2,fade=in:0:5,fade=out:20:5[v1];
48 [1]scale=w=960:h=720:force_original_aspect_ratio=1,
49 pad=960:720:(ow-iw)/2:(oh-ih)/2,
50 setsar=1,
51 loop=loop=50:size=2,fade=in:0:5,fade=out:45:5[v2];
52 [2]scale=w=960:h=720:force_original_aspect_ratio=1,
53 pad=960:720:(ow-iw)/2:(oh-ih)/2,
54 setsar=1,
55 loop=loop=75:size=2,fade=in:0:5,fade=out:75:5[v3];
56 
57 [v1][v2][v3]concat=n=3[v]" \
58 -map '[v]' -map '3:a' -shortest cat2.mp4

ふんがー

めんどくせー。やりたいことが単純だということは、「指示」も単純「であるべき」なわけで、「写真1を1秒、写真2を2.3秒、…」だけをつらつらと書き連ねるだけで目的のものが出来てほしかろう、てなわけで、雑でもいいからと Python スクリプトにしてしまおうか、つー話。

ワタシの性格的にもこれを読んでいるあなたにとっても「将来的には」なことは考えるであろうから、「指示」は多少は拡張性を見越したものにしたいので、こんな json から始めてみることにする:

 1 {
 2     "inputs": {
 3         "audio": {"file": "Babel1.mp4"},
 4         "images": [
 5             {"file": "stills_2/302.png", "duration": 2.5},
 6             {"file": "stills_2/303.png", "duration": 2.5},
 7             {"file": "stills_2/304.png", "duration": 2.5}
 8         ]
 9     }
10 }

やりたそうなこと、まで考えると「任意の時刻に挿入」までやりたくなりそうなのだが、この場合ギャップ萌え埋めが煩雑になるので今回はやらない。今は入力の静止画を連続で連結するだけのものにしておく。けれどこの json 形式ならば、そうしたくなった場合の拡張はしやすい。

このような指示 json を入力にし、作る python スクリプトは上で挙げたような bash スクリプトを生成するものとしとく。subprocess でダイレクトに ffmpeg を起こすことも出来るけど、出来上がった bash スクリプトを手作業で編集してしまえるメリットもなんだかんだ大きいので、「ダイレクトで起動できる方が唯一絶対的な正義」ってわけではない。

作る Python スクリプトの主たる責務は要するに「一つ何かを変えたければ芋づるでほかが引きずられて要編集となる」な人間にとって煩雑な一連の行為(計算)である。ゆえ、そこそこめんどくて、ソースコードは整理する気がなければ読みづらいものになるので覚悟。ひとまず飾り気のなく、柔軟性もない最もシンプルな初版:

 1 #! /bin/env python
 2 # -*- coding: utf-8 -*-
 3 from __future__ import unicode_literals
 4 from __future__ import absolute_import
 5 
 6 
 7 #{
 8 #    "inputs": {
 9 #        "audio": {"file": "Babel1.mp4"},
10 #        "images": [
11 #            {"file": "stills_2/302.png", "duration": 2.5},
12 #            {"file": "stills_2/303.png", "duration": 2.5},
13 #            {"file": "stills_2/304.png", "duration": 2.5}
14 #        ]
15 #    }
16 #}
17 import sys
18 import json
19 import math
20 
21 
22 def _dur_to_nframes(dur, fps):
23     return int(math.ceil(dur * fps))
24 
25 
26 def _build(defs):
27     aud = defs["inputs"]["audio"]["file"]
28     imgs = defs["inputs"]["images"]
29 
30     fc = []
31     for i, img in enumerate(imgs):
32         # each image
33         fc.append("""\
34 [{idx}]
35 scale=w=960:h=720:force_original_aspect_ratio=1,
36 pad=960:720:(ow-iw)/2:(oh-ih)/2,
37 setsar=1,
38 loop=loop={loop}:size=2
39 [v{idx}]
40 """.format(
41                 idx=i,
42                 loop=_dur_to_nframes(img["duration"], fps=25)))
43 
44     # concat
45     fc.append(
46         "{}concat=n={}[v]".format(
47             "".join(["[v%d]" % idx for idx in range(len(imgs))]),
48             len(imgs)
49             ))
50 
51     # build bash script
52     script = r"""#! /bin/sh
53 
54 ffmpeg -y \
55 {image_inputs} {audio_input} \
56     -c:v libx264 \
57     -pix_fmt yuv420p \
58     -r 25 \
59     -filter_complex "
60 {filter_complex}
61 " -map '[v]' -map '{audidx}:a' -shortest {outname}
62 """.format(
63         image_inputs=" ".join(["-i '%s'" % img["file"] for img in imgs]),
64         audio_input="-i '{}'".format(aud),
65         filter_complex=";\n".join(fc),
66         audidx=len(imgs),
67         outname="result.mp4")
68 
69     return script
70 
71 
72 if __name__ == '__main__':
73     import argparse
74 
75     parser = argparse.ArgumentParser()
76     parser.add_argument("definition")
77     args = parser.parse_args(sys.argv[1:])
78 
79     defs = json.loads(open(args.definition).read())
80     #print(json.dumps(defs, indent=2))
81     print(_build(defs))

ひとまず「始められる」状態ではあり、今すぐ出来る拡張をして「今日のワタシ」ニーズに耐えるものにはすぐ出来るけど、いったんここまでの時点で本日 21:00 時点での投稿を締めておく。「ちゃんとしたもの」にする気はあんまりないけれど、「少なくともこれは欲しい」水準はあるので、それについては追記の形で掲載予定。しばしお待たれな。

ふんがー・ゼロ (12/16 スクリプトの微拡張)

予告どおり基本的に自分が当座の用を足せればいいというノリはいつも通りであって、真面目な整理するつもりは毛頭ない、なレベルの、だけれども「少しは使える」やーつ:

  1 #! c:/Python35/python.exe
  2 # -*- coding: utf-8 -*-
  3 from __future__ import unicode_literals
  4 from __future__ import absolute_import
  5 
  6 
  7 #{
  8 #    "inputs": {
  9 #        "audio": {"file": "Babel1.mp4"},
 10 #        "images": [
 11 #            {"file": "stills_2/302.png", "duration": 2.5},
 12 #            {"file": "stills_2/303.png", "duration": 2.5},
 13 #            {"file": "stills_2/304.png", "duration": 2.5}
 14 #        ]
 15 #    }
 16 #}
 17 import sys
 18 import json
 19 import math
 20 import os
 21 
 22 
 23 def _dur_to_nframes(dur, fps):
 24     return int(math.ceil(dur * fps))
 25 
 26 
 27 def _is_still(fn):
 28     # TODO: more flexible
 29     return os.path.splitext(fn)[1] in (".png", ".jpg")
 30 
 31 
 32 def _build(defs, outprm):
 33     aud = defs["inputs"]["audio"]["file"]
 34     imgs = defs["inputs"]["images"]
 35 
 36     fc = []
 37     for i, img in enumerate(imgs):
 38         # each image
 39         content = ""
 40         if _is_still(img["file"]):
 41             dur = _dur_to_nframes(img.get("duration", 1.0), fps=outprm["fps"])
 42             content = "loop=loop={}:size=2,".format(dur)
 43         else:  # movie
 44             # TODO: when the duration is not specified, take it from the media.
 45             # (maybe we must use "ffprobe".)
 46             if "duration" in img:
 47                 dur = img["duration"]
 48                 content = "trim=0.000:{},setpts=PTS-STARTPTS,".format(dur)
 49         #
 50         extra = ""
 51         if img.get("v_extra_filter"):
 52             extra = img["v_extra_filter"] + ","
 53         #
 54         fade = ""
 55         fadea = []
 56         if "fade_in" in img:
 57             fid = _dur_to_nframes(img["fade_in"], fps=outprm["fps"])
 58             fadea.append("fade=in:0:{}".format(fid))
 59         if "fade_out" in img:
 60             fod = _dur_to_nframes(img["fade_out"], fps=outprm["fps"])
 61             fadea.append("fade=out:{}:{}".format(dur - fod, fod))
 62         if fadea:
 63             fade = ",".join(fadea) + ","
 64         fc.append("""\
 65 [{idx}]
 66 fps={fps},
 67 scale=w={w}:h={h}:force_original_aspect_ratio=1,
 68 pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,
 69 {content}
 70 {extra}
 71 {fade}
 72 setsar=1
 73 [v{idx}]
 74 """.format(
 75                 idx=i,
 76                 fps=outprm["fps"],
 77                 w=outprm["width"],
 78                 h=outprm["height"],
 79                 extra=extra,
 80                 fade=fade,
 81                 content=content))
 82 
 83     # concat
 84     fc.append(
 85         "{}concat=n={}[v]".format(
 86             "".join(["[v%d]" % idx for idx in range(len(imgs))]),
 87             len(imgs)
 88             ))
 89 
 90     # build bash script
 91     script = r"""#! /bin/sh
 92 
 93 ffmpeg -y \
 94 {image_inputs} {audio_input} \
 95     -c:v libx264 \
 96     -pix_fmt yuv420p \
 97     -filter_complex "
 98 {filter_complex}
 99 " -map '[v]' -map '{audidx}:a' -shortest {outfilename}
100 """.format(
101         image_inputs=" ".join(["-i '%s'" % img["file"] for img in imgs]),
102         audio_input="-i '{}'".format(aud),
103         filter_complex=";\n".join(fc),
104         audidx=len(imgs),
105         outfilename=outprm["outfilename"])
106 
107     return script
108 
109 
110 if __name__ == '__main__':
111     import argparse
112 
113     parser = argparse.ArgumentParser()
114     parser.add_argument("definition")
115     parser.add_argument("--width", type=int, default=1920)
116     parser.add_argument("--height", type=int, default=1080)
117     parser.add_argument("--framerate", type=float, default=25)
118     parser.add_argument("--outfilename", type=str, default="result.mp4")
119     args = parser.parse_args(sys.argv[1:])
120 
121     defs = json.loads(open(args.definition).read())
122     #print(json.dumps(defs, indent=2))
123     print(_build(defs, {
124                 "fps": args.framerate,
125                 "width": args.width,
126                 "height": args.height,
127                 "outfilename": args.outfilename
128                 }))

入力の json は少しだけ記述できることが増えてこんな具合:

 1 {
 2     "inputs": {
 3         "audio": {"file": "audio.mp4"},
 4         "images": [
 5             {"file": "../images/aaa01-01.png", "duration": 0.5, "fade_in": 0.2, "fade_out": 0.2},
 6             {"file": "../images/aaa01-02.png", "duration": 0.5, "fade_in": 0.2, "fade_out": 0.2},
 7             {"file": "../images/aaa01-03.png", "duration": 0.5, "fade_in": 0.2, "fade_out": 0.2},
 8             {"file": "../images/blank.png", "duration": 3},
 9             {"file": "../images/aaa02.png", "duration": 1, "fade_out": 0.5},
10             {"file": "../images/blank.png", "duration": 1},
11 
12             {"file": "../images/aaa02.png", "duration": 93, "fade_out": 0.5},
13             {"file": "../images/bbb04.png", "duration": 0.5, "fade_in": 0.2, "fade_out": 0.2},
14             {"file": "../images/aaa02.png", "duration": 0.5, "fade_out": 0.2},
15             {"file": "../images/bbb01.png", "duration": 0.5, "fade_in": 0.2, "fade_out": 0.2},
16 
17             {"file": "../images/aaa02.png", "duration": 21, "fade_in": 0.5},
18             {"file": "../images/aaa02.png", "duration": 10, "v_extra_filter": "edgedetect=mode=colormix,avgblur=5"},
19 
20             {"file": "../images/aaa02.png", "duration": 125.5, "fade_in": 0.5, "fade_out": 0.5},
21             {"file": "movie.mp4"},
22             {"file": "../images/aaa02.png", "duration": 106, "fade_in": 0.5, "fade_out": 9}
23         ]
24     }
25 }

新たに出来るようにしたのは以下:

  1. フェードイン/フェードアウトを指定出来るように。
  2. 動画もぶちこめるように。
  3. 自前でフィルターを追加出来るように(v_extra_filter)。

フェードイン/アウトは静止画についてしか使えない(unbound local で落ちる、今のところ)。使えるように拡張するのは「難しくはないが面倒」。入力の duration をプログラムが知る必要があるから、てだけで、これは簡単で面倒。

改造前から出来てなくて、改造する気は今のところないのが、最初の投稿時にも書いた「開始時刻指定」。今のままだと「積み上げ」で作っていくしかないので、動画の後ろの方が前の方の変更の影響を常に受け続けるので、しじょーにメンテしづらい。が、これに対応するプログラムは多分あなたが考えるよりも数倍煩雑。ほかで紹介した alignment_info_by_sound_track はまさにこれをしてるが、かなり入り組んでいる。

なお、「動画もぶちこめるように」がアタシには嬉しい誤算だった。この記事の見出しの通り、もともと「静止画に基づいて動画を作る」から始めたわけだが、ふと「あら、出来るわ」と気付き、して、実際に静止画も動画も区別なく使えると、かなり楽しげな動画を比較的簡単に作れる。

ちなみにフェードイン/アウトと「v_extra_filter」ってのが「ソフトウェアのあるべき姿」からは少し遠いことはわかってやっておる。「v_extra_filter」だけで済ますとか、あるいは「v_extra_filter」の裏ということなら「v_predefined_filter」みたいな対応が綺麗である。けど今はどうでもいい。「ワタシの今」に耐えれば良いのであるし、「やりたいことから考えりゃ、一番ニーズが多いのは確実にフェードイン/アウトでしょ、だからこれが浮いてても気にしない気にしない」てことだわ。

12/20 些細な修正

こういうリビジョン管理対象にしないようなゴミスクリプトの「改造」をちまちまブログ内で更新していこうとするのも「キリがない」んで、小さな修正を上げてくのも躊躇してしまうわ。て気分もあるんだけど、あんまりにあんまりなよろしくなげっぷりなままなのも気持ちも良くないので。

上のスクリプト、「空白を含む出力ファイル名がダメ」てのと、あと「音声ファイルが必須なのもなぁ」の2点を修正:

  1 #! c:/Python35/python.exe
  2 # -*- coding: utf-8 -*-
  3 from __future__ import unicode_literals
  4 from __future__ import absolute_import
  5 
  6 
  7 #{
  8 #    "inputs": {
  9 #        "audio": {"file": "Babel1.mp4"},
 10 #        "images": [
 11 #            {"file": "stills_2/302.png", "duration": 2.5},
 12 #            {"file": "stills_2/303.png", "duration": 2.5},
 13 #            {"file": "stills_2/304.png", "duration": 2.5}
 14 #        ]
 15 #    }
 16 #}
 17 import sys
 18 import json
 19 import math
 20 import os
 21 
 22 
 23 def _dur_to_nframes(dur, fps):
 24     return int(math.ceil(dur * fps))
 25 
 26 
 27 def _is_still(fn):
 28     # TODO: more flexible
 29     return os.path.splitext(fn)[1] in (".png", ".jpg")
 30 
 31 
 32 def _build(defs, outprm):
 33     aud = ""
 34     if "audio" in defs["inputs"]:
 35         aud = defs["inputs"]["audio"]["file"]
 36     imgs = defs["inputs"]["images"]
 37 
 38     fc = []
 39     for i, img in enumerate(imgs):
 40         # each image
 41         content = ""
 42         if _is_still(img["file"]):
 43             dur = _dur_to_nframes(img.get("duration", 1.0), fps=outprm["fps"])
 44             content = "loop=loop={}:size=2,".format(dur)
 45         else:  # movie
 46             # TODO: when the duration is not specified, take it from the media.
 47             # (maybe we must use "ffprobe".)
 48             if "duration" in img:
 49                 dur = img["duration"]
 50                 content = "trim=0.000:{},setpts=PTS-STARTPTS,".format(dur)
 51         #
 52         extra = ""
 53         if img.get("v_extra_filter"):
 54             extra = img["v_extra_filter"] + ","
 55         #
 56         fade = ""
 57         fadea = []
 58         if "fade_in" in img:
 59             fid = _dur_to_nframes(img["fade_in"], fps=outprm["fps"])
 60             fadea.append("fade=in:0:{}".format(fid))
 61         if "fade_out" in img:
 62             fod = _dur_to_nframes(img["fade_out"], fps=outprm["fps"])
 63             fadea.append("fade=out:{}:{}".format(dur - fod, fod))
 64         if fadea:
 65             fade = ",".join(fadea) + ","
 66         fc.append("""\
 67 [{idx}]
 68 fps={fps},
 69 scale=w={w}:h={h}:force_original_aspect_ratio=1,
 70 pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,
 71 {content}
 72 {extra}
 73 {fade}
 74 setsar=1
 75 [v{idx}]
 76 """.format(
 77                 idx=i,
 78                 fps=outprm["fps"],
 79                 w=outprm["width"],
 80                 h=outprm["height"],
 81                 extra=extra,
 82                 fade=fade,
 83                 content=content))
 84 
 85     # concat
 86     fc.append(
 87         "{}concat=n={}[v]".format(
 88             "".join(["[v%d]" % idx for idx in range(len(imgs))]),
 89             len(imgs)
 90             ))
 91 
 92     # build bash script
 93     script = r"""#! /bin/sh
 94 
 95 ffmpeg -y \
 96 {image_inputs} {audio_input} \
 97     -c:v libx264 \
 98     -pix_fmt yuv420p \
 99     -filter_complex "
100 {filter_complex}
101 " -map '[v]' {audmap} -shortest '{outfilename}'
102 """.format(
103         image_inputs=" ".join(["-i '%s'" % img["file"] for img in imgs]),
104         audio_input="-i '{}'".format(aud) if aud else "",
105         filter_complex=";\n".join(fc),
106         audmap="-map '{audidx}:a'".format(audidx=len(imgs)) if aud else "",
107         outfilename=outprm["outfilename"])
108 
109     return script
110 
111 
112 if __name__ == '__main__':
113     import argparse
114 
115     parser = argparse.ArgumentParser()
116     parser.add_argument("definition")
117     parser.add_argument("--width", type=int, default=1920)
118     parser.add_argument("--height", type=int, default=1080)
119     parser.add_argument("--framerate", type=float, default=25)
120     parser.add_argument("--outfilename", type=str, default="result.mp4")
121     args = parser.parse_args(sys.argv[1:])
122 
123     defs = json.loads(open(args.definition).read())
124     #print(json.dumps(defs, indent=2))
125     print(_build(defs, {
126                 "fps": args.framerate,
127                 "width": args.width,
128                 "height": args.height,
129                 "outfilename": args.outfilename
130                 }))