【試行錯誤】「〇〇で歌ってみた」動画の自動生成 その５：静止画をつなげて動画化

# 概要
前回は、動画の素材となる静止画を作成しました。今回はそれをつなげて動画をつくります。
最終的に以下のような動画ができました。

<iframe width="560" height="315" src="https://www.youtube.com/embed/D-DOLZ7P7lQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>


シリーズ一覧は以下です。
[【試行錯誤】「〇〇で歌ってみた」動画の自動生成 リンクまとめ](https://qiita.com/shimajiroxyz/items/5df23a0ca81a3a2d9568)

# 方針
以下のステップで作成します。

* 事前準備
* 無音動画の作成
* 無音動画と音声の結合

# 事前準備
## 替え歌情報
「その３」で作った替え歌情報のデータフレームを読み込んでおきます。

```:output/harugakita/parodyinfo.csv
parodytext,start,duration,originaltext,picture_path
マルタ,2.4,1.1999999999999997,春が来た　春が来た　どこに来た,Flag_of_Malta.png
ニカラグア,3.6,2.399999999999998,春が来た　春が来た　どこに来た,Flag_of_Nicaragua.png
イラン,5.999999999999998,1.1999999999999993,春が来た　春が来た　どこに来た,Flag_of_Iran.png
コロンビア,7.1999999999999975,4.200000000000001,春が来た　春が来た　どこに来た,Flag_of_Colombia.png
ハンガリー,11.999999999999998,1.2000000000000028,山に来た　里に来た　野にも来た,Flag_of_Hungary.png
...
```

```Python
import pandas as pd
parodyinfo_df = pd.read_csv("output/harugakita/parodyinfo.csv")
```

## 静止画
「その４」で作成した画像を一つのフォルダにおいておきます。
今回は`output/figs`においてあるものとします。

## 歌唱音声ファイル
タイムスタンプの情報を抽出したMusicXMLで歌唱ファイルを作成しておきます。
本来は替え歌歌詞を歌ったファイルの方がいいですが、本記事においては画像の切り替えタイミングとの同期度合いがざっくりわかればよいので、すでに作ってある元歌詞の歌唱データをつかっています。

# 無音動画の生成
無音動画を作ります。ライブラリは選択肢がいくつかありますが、今回は`opencv`にします。`moviepy`も使い勝手が良さそうですが、自環境ではバグがいろいろ起こったので見送りました。

```Python
import cv2
import numpy as np
import pandas as pd
from pathlib import Path

# 動画の出力先の設定とフォルダ作成
OUT_PATH = Path("output/video")
OUT_PATH.mkdir(parents=True, exist_ok=True)
# 静止画の保存ディレクトリ
FIG_DIR = Path("output/figs")

# タイムスタンプ情報の読み込み
parodyinfo_df = pd.read_csv("output/parodyinfo.csv")
# 画像の終了時刻と次の画像の開始時刻が離れていた場合に挿入するための黒駒の生成
def get_blank_cv2_image(width, height, rgb_color = [0, 0, 0]):
    #ブランク画像
    blank = np.zeros((height, width, 3))
    blank += rgb_color[::-1] # cv2はBGRなので
    blank = blank.astype(np.uint8) #cv2が読み込める型に変換
    return blank
# 動画書き込み用オブジェクトの宣言
fourcc = cv2.VideoWriter_fourcc('m','p','4', 'v') # 拡張子 mp4
frame_rate = 20 # フレームレート
video  = cv2.VideoWriter(str(OUT_PATH.joinpath('ImgVideo.mp4')), fourcc, frame_rate, (1920, 1080))
# 以下でフレームの書き込みを実装
# 書き込み途中の動画のフレーム枚数
current_frame = 0 
# この値・フレーム以上、次の画像の表示まで時間が空いていたら、黒駒を挿入する
minimum_interval_second = 3
minimum_interval_frame = int(minimum_interval_second * frame_rate) # 基本的にフレーム単位で処理
last_image = cv2.imread(str(FIG_DIR.joinpath("0.png")) # 画像間のブランク時間に挿入するため、直前の画像を保持
blank_image = get_blank_cv2_image(1920, 1080, [0,0,0]) # 画像間のブランク時間に挿入するための、黒駒の生成
# parodyinfoの各行からタイムスタンプを取得して、持続時間に応じた枚数のフレームを挿入する処理
for i, (index, row) in enumerate(parodyinfo_df.iterrows()):
    file_path = str(FIG_DIR.joinpath("{}.png".format(i))) # 画像パスを取得
    # 静止画の開始時間、持続時間を取得してフレームに変換
    start, duration = row["start"], row["duration"] 
    start_frame, duration_frame = int(start * frame_rate), int(duration * frame_rate)
    # 現フレーム数と静止画の開始時間の差を取得
    diff_frame = start_frame - current_frame
    # diff_frameの処理。
    # もしdiffが長いなら黒駒、短ければ直前の画像で埋める
    if diff_frame > minimum_interval_frame:
        complement_image = blank_image
    else:
        complement_image = last_image
    for _ in range(diff_frame):
        video.write(complement_image)

    # duration_frameの枚数分、画像を追加
    image = cv2.imread(file_path) # 静止画の取得
    for _ in range(duration_frame):
        video.write(image)
    # フレーム数、直前画像の更新
    current_frame = start_frame + duration_frame
    last_image = image
# ビデオオブジェクトの開放（書き込みの終了）
video.release()
```

## 解説
cv2で静止画をつなぎ合わせる方法は以下を参考にしました。
[Python3+OpenCVで静止画から動画を作成する](https://qiita.com/moshi/items/cc7fd0b3c1b008c56a13)

今回は、静止画ごとに表示持続時間を変えたいので、そのあたりの処理を変更しています。
`video.write`のときに秒数を指定するようなオプションはなかったので、持続時間とフレーム数からフレーム枚数を計算して、その分だけ追加しています。しかし、これを愚直に実装すると、端数の切り捨てで誤差が蓄積されていく可能性があるので、画像の再生時間を把握しておき、start時間とズレが有る場合には適当な量のフレームを追加する処理をループごとに入れています。なお、「時間」と書きましたが、上記ではフレーム数ベースで計算するようにしています。

また、画像の表示終了時刻と、次の画像の表示開始時間に大幅な差があるときには黒駒を挿入するようにしています。逆に差がないときには直前の画像を次の画像の表示ぎりぎりまで表示するようにします。

## 無音動画と音声の結合
`ffmpeg`をpythonの`subprocess`で実行して結合します。

```Python
# 以下を参考に実装
# ffmpegの使い方： https://qiita.com/niusounds/items/f69a4438f52fbf81f0bd
# subprocessの使い方： https://www.mathkuro.com/python/subprocess-popular-usage/
import subprocess
from pathlib import Path
# 動画と画像をffmpegで結合する
def join_movie_and_audio_by_ffmpeg(video_path, audio_path, output_path, *, overwrite = True, make_parent_dir = True):
  if make_parent_dir:
    Path(output_path).parent.mkdir(parents=True, exist_ok=True)
  cmd = ["ffmpeg"]
  if overwrite:
    cmd += ["-y"] # -yでファイル上書き
  cmd += ["-i", video_path] #動画
  cmd += ["-i", audio_path] #音声
  cmd += ["-c:v", "copy", "-c:a", "aac"] # 動画、音声の出力フォーマット
  cmd += ["-map", "0:v:0", "-map", "1:a:0"] # 元動画に音声がある場合でも上書きする。
  cmd += [ output_path ] #出力ファイル
  try:
    # capture_output=True, text=Trueを指定
    result = subprocess.run(cmd, capture_output=True, text=True)

    # .stdoutに標準出力、.stderrに標準エラー出力が格納される
    print(result.stdout)
    print(result.stderr)
  except subprocess.CalledProcessError as e:
    # 終了コードが0以外の場合、例外が発生
    print(e)
    # > Command '['ls', '/hoge/fuga']' returned non-zero exit status 1.

join_movie_and_audio_by_ffmpeg("output/video/ImgVideo.mp4", "NEUTRINO/output/sample1_nsf.wav","output/video/ImgVideo_audio.mp4")
```

`output/video/ImgVideo_audio.mp4`を開いて確認すると、冒頭に示した動画ができていました。タイミングもままあっていて良い感じです。

# おわりに
無事、そこそこ音楽と一致するタイミングで替え歌歌詞を表示する動画を自動生成する事ができました。
次は、替え歌歌詞の自動生成機能を簡易に実装してみようと思います。

# 参考：しくじり記録
pythonで動画を扱うときに[MoviePy](https://zulko.github.io/moviepy/index.html)が便利そうだったので、最初使おうとしたのですが、いろいろバグに遭遇して採用を見送ってしまいました。

主に無音動画と音声の結合のところでmoviepyを使おうとしていました。
moviepyで動画に音声を付ける方法は以下などで説明されています（`set_audio`関数）。

[OpenCV で動画を編集し、音声つきで保存する](https://kp-ft.com/684)
[動画からの音声抽出と動画への音声結合](https://testpy.hatenablog.com/entry/2018/03/22/003000)

ただこれをそのまま試すとエラーになってしまいました。以下に書かれている対策を一通り試して、最終的にはエラーなく実行できるようにはなったのですが、MacのQuickTimePlayerだと音声がなぜか出てこなかったり（VLCとか他の動画再生ソフトだと聴こえる）、そもそもどの対応が解決に効いたのかよくわからない感じだったので、採用を見送りました。

[Getting "TypeError: must be real number, not NoneType" whenever trying to run write_videofile to a clip in moviepy](https://stackoverflow.com/questions/68032884/getting-typeerror-must-be-real-number-not-nonetype-whenever-trying-to-run-wr)

上記を一通り試して、最終的にうまくいった（といってもQuickTimePlayerではうまく再生できない）コードは以下の様な感じです。

```Python
import moviepy.editor as mp
from pathlib import Path

OUT_PATH = Path("output/video")
# Add audio to output video.
clip = mp.VideoFileClip(str(OUT_PATH.joinpath('ImgVideo.mp4'))).subclip()
#clip.set_audio('NEUTRINO/output/sample1_nsf.wav')
audioclip = mp.AudioFileClip('NEUTRINO/output/sample1_nsf.wav')
clip = clip.set_audio(audioclip)
clip.write_videofile(str(OUT_PATH.joinpath("ImgVideo_audio.mp4")))
```

QuickTimePlayerで音声出力されない問題については以下で解決可能かもしれません。ただmoviepy自体になんとなく頼りなさを感じてしまったので、そこまで踏み込みませんでした。

参考：[MoviePyを使ったらいろいろつまづいたのでメモ](https://qiita.com/s2hap/items/74d496ce4703d92cece6)

MoviePyは内部でffmpegを使っているっぽいので、ffmpegコマンドを直接叩いてみると、QuickTimePlayerでも音声再生可能な動画が出力できました。

参考：[ffmpegを使って映像と音声を結合する](https://qiita.com/niusounds/items/f69a4438f52fbf81f0bd)

そこで、一応pythonでも実行できるように、上記記事の処理をほぼそのままsubprocess荷移行して最終的にうまくいきました。

ちなみに、ffmpegのpython wrapper（ffmpeg-python）というのもあってそっちも試してみたのですが、こちらはMoviePyと同様にQuickTimePlayerで音声が再生できない問題が発生してしまいました。

```
pip install ffmpeg-python
```

```Python
# 参考
# https://qiita.com/studio_haneya/items/a2a6664c155cfa90ddcf
import ffmpeg
instream_v = ffmpeg.input("output/ImgVideo.mp4")
instream_a = ffmpeg.input("NEUTRINO/output/sample1_nsf.wav")
stream = ffmpeg.output(instream_v, instream_a, "output/ImgVideo_audio.mp4", vcodec="copy", acodec="aac")
#ffmpeg.run(stream, overwrite=True)
stream.run(overwrite_output=True)

```

```Python
# 参考
# https://qiita.com/studio_haneya/items/a2a6664c155cfa90ddcf
import ffmpeg
instream_v = ffmpeg.input("output/ImgVideo.mp4")
instream_a = ffmpeg.input("NEUTRINO/output/sample1_nsf.wav")
stream = ffmpeg.output(instream_v, instream_a, "output/ImgVideo_audio.mp4", vcodec="copy", acodec="aac")
#ffmpeg.run(stream, overwrite=True)
stream.run(overwrite_output=True)
```

あと無音動画をつくるとき、moviepyだと各画像の表示時間を秒数指定できるようです。ただ、表示時間の同期などを考えるとOpenCVで作るときと面倒くささはあまり変わらないような気もします。

# 参考にさせていただいた記事
* [Python3+OpenCVで静止画から動画を作成する](https://qiita.com/moshi/items/cc7fd0b3c1b008c56a13)
* [OpenCV で動画を編集し、音声つきで保存する](https://kp-ft.com/684)
* [MoviePy公式ドキュメント](https://zulko.github.io/moviepy/index.html)
* [Pythonで複数枚の静止画を動画化する](https://qiita.com/showchan33/items/968a07c078bf0a7f9e46)
* [ffmpegを使って映像と音声を結合する](https://qiita.com/niusounds/items/f69a4438f52fbf81f0bd)
* [ffmpeg-pythonで動画編集する](https://qiita.com/studio_haneya/items/a2a6664c155cfa90ddcf)
    * `pip install ffmpeg`ではなく`pip install ffmpeg-python`
* [【Python】subprocessのよく使う奴だけまとめたサンプルコード集](https://www.mathkuro.com/python/subprocess-popular-usage/)