Rubyを使ってffmpegによる演奏動画の編集を簡単に

#1. はじめに
最近の音楽の演奏者のトレンドとして，一箇所に集合して一緒に演奏出来ないのでそれぞれに動画を撮り，後で編集して1つの動画を作って公開，と言う流れがあります．
私は音楽の知識はあまりなく，自分で演奏する事は基本的にありません．ですが，身近に音楽関係者がおり，動画の編集を頼まれる事が多くなってきました．これに対応するためにRubyを使ってffmpegを動かし，半自動で動画の編集が出来る環境を作りました．

![ruby_movie_ffmpeg.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/652281/2356b3f2-39df-7f83-66ae-4e07f6d1396f.png)

#2. 処理の流れ
今はスマートフォン等を使って知識の無い人でも簡単に演奏動画を撮影する事が出来ます．･･･が，それ故に皆が様々なフォーマット，動画サイズ，フレームレートで動画を送ってくると言う事でもあります．もっと動画の劣化を防ぐ上手い使い方もあるとは思っていますが，以下のように段階を踏む方法で処理しています．

 * リサイズ
 * 演奏開始/終了のタイミングを調整
 * フェードイン，クロッピング(切り抜き)
 * 音量を変更
 * 上記を動画の数の分繰り返し
 * 調整後の複数の動画を，並べて単体の動画に合成

#3. ソース
Windows環境で動かす事を想定しているため，参考にする方は文字\の使い方等に注意して下さい．

```ruby:movie_on_playing.rb
control_file = ARGV[0]
cfile = File.open(control_file, "r")

def system_log( exe, workd )
  fho = File.open("#{workd}\\log.txt", "a")
  fho.write("#{exe}\n")
  fho.close
  res = system( exe )
  unless(res)
    puts "# Error! ffmpeg failed. See #{workd}\\log.txt to confirm command."
    exit
  end
end

out_width  = 0
out_height = 0
movie      = []
resize     = []
volume     = []
fadein     = []
delay      = []
delay_f    = []
to_time    = []
blackf     = []
crop_xy    = []
crop_size  = []
skip       = []
row_div    = []
row_flag   = true
ri = 0

for s1 in cfile
  next if s1 =~ /^#/
  a1 = s1.chomp.split(" ")
  if(a1[0] == "ffmpeg")
    ffmpeg = a1[1]
    next
  end
  if(a1[0] == "work")
    workd = a1[1]
    next
  end
  if(a1[0] == "out")
    out_file_name = a1[1]
    system("title #{out_file_name}")
    next
  end
  if(a1[0] == "movie")
    movie << a1[1]
    next
  end
  if(a1[0] == "resize")
    resize << a1[1]
    next
  end
  if(a1[0] == "volume")
    volume << a1[1]
    next
  end
  if(a1[0] == "fadein")
    fadein << a1[1]
    next
  end
  if(a1[0] == "delay")
    delay << a1[1]
    delay_f << a1[2] if(a1[1].to_f > 0)
    next
  end
  if(a1[0] == "time")
    to_time << a1[1]
    next
  end
  if(a1[0] == "crop")
    crop_xy << a1[1]
    crop_size << a1[2]
    if(row_div.size == 0)
      out_width = out_width + a1[2].split("x")[0].to_i
    end
    if row_flag
      out_height = out_height + a1[2].split("x")[1].to_i
      row_flag = false
    end
    next
  end
  if(a1[0] == "skip")
    skip << a1[1]
    next
  end
  if(a1[0] == "row_div")
    row_div << movie.size
    row_flag = true
    next
  end
end

cfile.close

Dir.mkdir(workd) unless File.exist?(workd)
efile_mp4 = []
efile_mp3 = []
ovl_vx = 0
ovl_vy = 0
ovl_a = []

fho = File.open("#{workd}\\log.txt", "a")
fho.write("\n#{Time.now}\n")
fho.close

out_width_e = out_width
out_height_e = out_height
fi = 0
w_reset = false
row_div << 9999

for i in 0...movie.size
  filen = movie[i].split("\\")[-1].split(".")[0]
  filee = movie[i].split("\\")[-1].split(".")[1]
  file_rsz = "#{filen}_#{resize[i]}"
  rx = resize[i].split("x")[0]
  ry = resize[i].split("x")[1]
  ####################
  #### resize part ###
  ####################
  
  if(delay[i].to_f > 0.0)
    fade_time = 0
  else
    fade_time = -1 * delay[i].to_f
  end
  if(delay[i].to_f > 0)
    if(fadein[i].to_i > 0)
      fade_in = "fade=in:0:#{fadein[i]},"
    else
      fade_in = ""
    end
    system_log( "#{ffmpeg} -y -i \"#{movie[i]}\" -vf \"#{fade_in}scale=#{rx}:#{ry}\" \"#{workd}\\#{file_rsz}.#{filee}\"", workd ) if(skip[i] == "off")
  else
    system_log( "#{ffmpeg} -y -i \"#{movie[i]}\" -vf \"scale=#{rx}:#{ry}\" \"#{workd}\\#{file_rsz}.#{filee}\"", workd ) if(skip[i] == "off")
  end
  ######################
  ### black mov part ###
  ######################
  if(delay[i].to_f > 0.0)
    dname = delay[i].gsub(".","p")
    bfile = "black_#{resize[i]}_#{dname}.#{filee}"
    if(delay_f[fi] == nil)
      frate = "30000/1001"
    else
      frate = delay_f[fi]
    end
    system_log( "#{ffmpeg} -y -f lavfi -i \"color=c=black:s=#{resize[i]}:r=#{frate}:d=#{delay[i]}\" -f lavfi -i \"aevalsrc=0|0:c=stereo:s=44100:d=#{delay[i]}\" \"#{workd}\\#{bfile}\"", workd ) if(skip[i] == "off")
    blackf << bfile
    fi = fi + 1
  else
    blackf << "black_0"
  end
  #######################
  ### concat/cut part ###
  #######################
  if(blackf[i] != "black_0")
    # add plus delay
    con_filen = "#{workd}\\concat_#{i}.txt"
    con_file = File.open(con_filen, "w")
    con_file.write("file #{workd}/#{blackf[i]}\n")
    con_file.write("file #{workd}/#{file_rsz}.#{filee}\n")
    con_file.close
    file_cn = "#{file_rsz}_con"
    system_log( "#{ffmpeg} -y -safe 0 -f concat -i \"#{con_filen}\" -c:v copy -c:a copy -map 0:v -map 0:a \"#{workd}\\#{file_cn}.#{filee}\"", workd ) if(skip[i] == "off")
  elsif(delay[i].to_f < 0)
    # add minus delay
    cut_time = -1 * delay[i].to_f
    file_ct  = "#{filen}_cut"
    system_log( "#{ffmpeg} -y -i \"#{workd}\\#{file_rsz}.#{filee}\" -ss #{cut_time} \"#{workd}\\#{file_ct}.#{filee}\"", workd ) if(skip[i] == "off")
    file_cn = file_ct
  else
    # not add delay
    file_cn = file_rsz
  end
  ###########################
  ### fade, crop, to part ###
  ###########################

  crop_x = crop_xy[i].split("x")[0]
  crop_y = crop_xy[i].split("x")[1]
  crop_w = crop_size[i].split("x")[0]
  crop_h = crop_size[i].split("x")[1]
  cropt = "crop=#{crop_w}:#{crop_h}:#{crop_x}:#{crop_y}"
  
  if(w_reset)
    out_width_e = out_width
    out_height_e = out_height_e - crop_h.to_i
    w_reset = false
  end

  if( out_width_e - crop_w.to_i > 0 || out_height_e - crop_h.to_i > 0)
    padt = ",pad=#{out_width_e}:#{out_height_e}:0:0"
  else
    padt = ""
  end
  if(to_time[i].to_f > 0)
    tot = "-to #{to_time[i].to_f + delay[i].to_f}"
  else
    tot = ""
  end
  file_crp = "#{file_rsz}_cropped"
  if(delay[i].to_f > 0)
    system_log( "#{ffmpeg} -y -i \"#{workd}\\#{file_cn}.#{filee}\" -vf \"#{cropt}#{padt}\" #{tot} \"#{workd}\\#{file_crp}.#{filee}\"", workd ) if(skip[i] == "off")
  else
    if(fadein[i].to_i > 0)
      fade_in = "fade=in:0:#{fadein[i]},"
    else
      fade_in = ""
    end
    system_log( "#{ffmpeg} -y -i \"#{workd}\\#{file_cn}.#{filee}\" -vf \"#{fade_in}#{cropt}#{padt}\" #{tot} \"#{workd}\\#{file_crp}.#{filee}\"", workd ) if(skip[i] == "off")
  end
  
  out_width_e = out_width_e - crop_w.to_i
  if(row_div.size > 0)
    if(i == row_div[ri] - 1)
      ovl_vx = 0
      ovl_vy = ovl_vy + crop_h.to_i
      ri = ri + 1
      w_reset = true
    else
      ovl_vx = ovl_vx + crop_w.to_i
    end
  else
    ovl_vx = ovl_vx + crop_w.to_i
  end
  ovl_a << [ovl_vx, ovl_vy]
  ############################
  ### mp3 out, volume part ###
  ############################
  vol_e = volume[i].to_f / 100
  system_log( "#{ffmpeg} -y -i \"#{workd}\\#{file_crp}.#{filee}\" -vn -acodec libmp3lame -ar 44100 -ab 256k -af \"volume=#{vol_e}\" \"#{workd}\\#{filen}.mp3\"", workd ) if(skip[i] == "off")
  efile_mp3 << "#{workd}\\#{filen}.mp3"
  efile_mp4 << "#{workd}\\#{file_crp}.#{filee}"
end

####################
### overlay part ###
####################
in_file = ""
for efile in efile_mp4
  in_file = "#{in_file} -i \"#{efile}\""
end
ovlt = ""
for i in 0...ovl_a.size - 1
  if(ovlt == "")
    ovlt = "overlay=x=#{ovl_a[i][0]}:y=#{ovl_a[i][1]}"
  else
    ovlt = "#{ovlt},overlay=x=#{ovl_a[i][0]}:y=#{ovl_a[i][1]}"
  end
end
system_log( "#{ffmpeg} -y#{in_file} -filter_complex \"#{ovlt}\" -an \"#{workd}\\out_movie.#{filee}\"", workd )
######################
### add audio part ###
######################
in_file = ""
for efile in efile_mp3
  in_file = "#{in_file} -i \"#{efile}\""
end
system_log( "#{ffmpeg} -y -i \"#{workd}\\out_movie.#{filee}\" #{in_file} -filter_complex \"amix=inputs=#{efile_mp3.size}:duration=longest:dropout_transition=2\" \"#{out_file_name}\"", workd )
```
##3.1 Control file
動画ファイルの所在，編集方法等の指定のために，control fileを作って読み込ませるようにしました．以下は一例です．他，"row_div"と言うキーワードを入れた後の動画は，横ではなく縦に連結されます．

```text:control.txt
ffmpeg C:\ffmpeg\bin\ffmpeg.exe
work   work
out    out.mp4

movie  org_data\a-san.mp4
resize 1280x720
volume 100
fadein 60
delay  1.5
time   208.5
crop   0x0 640x720
skip   off

movie  org_data\b-san.mp4
resize 1280x720
volume 120
fadein 60
delay  -2.5
time   212.5
crop   320x0 640x720
skip   off
```

```
ruby movie_on_playing.rb control.txt
```
のようなコマンドで，controlの情報を基に，ffmpegのコマンドが順次生成され，実行されます．

#4. 処理の解説
注釈部の○○ partに従って，各パートの処理を説明します．

##4.1. Resize part
control fileの"resize"の設定に従って，動画のリサイズを実行します．リサイズの値を"scale="の後に入れ，以下のコマンドを生成します．

```
ffmpeg.exe -y -i "org_data\a-san.mp4" -vf "scale=1280:720" "work\a-san_1280x720.mp4"
```

"delay"の値が＋の場合は，前に何も映らない動画を連結させるため，ここでfade inの設定もしてしまいます．

```
ffmpeg.exe -y -i "org_data\a-san.mp4" -vf "fade=in:0:30,scale=1280:720" "work\a-san_1280x720.mp4"
```

##4.2. Black movie part
"delay"の値が＋の場合(動画の開始を遅らせる場合)，指定した秒数分の無音(注：音無しではない)，映像なしの動画を作成します．値を"d="の後に入れ，以下のコマンドを生成します．

```
ffmpeg.exe -y -f lavfi -i "color=c=black:s=1280x720:r=30000/1001:d=1.5" -f lavfi -i "aevalsrc=0|0:c=stereo:s=44100:d=1.5" "work\black_1280x720_1p5.mp4"
```

この処理は連結後の動画に不具合が起きやすいため，"delay [秒数] [フレームレート]"と，3カラム目でフレームを変えられるようにしました．しかし，この方法も上手く行かない場合もあるため，ffmpegを使った動画編集の際には，元の動画のマージンを十分に取った状態で撮影し，削る方が処理は簡単です．これから動画を撮影してもらう場合は演奏者に，｢録画ボタンを押して，5秒くらい待ってから演奏してね．｣と言いましょう．

##4.3. Concat/cut part
"delay"の値が－の場合(動画を早く開始させる場合)，"-ss"の値に入れ，以下のコマンドを生成します．

```
ffmpeg.exe -y -i "work\a-san_1280x720.mp4" -ss 2.5 "work\a-san_1280x720_cut.mp4"
```

"delay"の値が＋の場合，先程の映像なしの動画と連結させます．

```
ffmpeg.exe -y -safe 0 -f concat -i "work\concat_1.txt" -c:v copy -c:a copy -map 0:v -map 0:a "work\a-san_1280x720_con.mp4"
```

##4.4. Fade, crop, to part
ここでは動画のクロッピング，終わる時間の指定を行います．"delay"の値が－の場合，ここでfade inの設定を行います．ffmpegではクロップ後のサイズ，クロップ位置の順番に指定しますが，個人的に少し分かり難かったため，control fileでは"crop [クロップ位置] [クロップ後のサイズ]"と言う書式にしています．"crop (X1)x(Y1) (X2)x(Y2)"→ffmpegの"crop=(X2):(Y2):(X1):(Y1)"のように変換されます．このように好みに応じて自分で書式を決められるのも，Rubyを使って制御する強みです．

"pad="の値はそれぞれの動画のクロップ後のサイズから計算しています．"-to"はcontrol fileの"time"と"delay"の値から算出します．

```
ffmpeg.exe -y -i "work\a-san_1280x720_cut.mp4" -vf "fade=in:0:30,crop=640:720:0:0,pad=1280:720:0:0" -to 210.0 "work\a-san_1280x720_cropped.mp4"
```

##4.5. Mp3 out, volume part
タイミングを合わせた(-ssと-toの設定が済んだ)動画から，音のみのファイルを抽出します．ffmpegでは音量100%は"1.0"となりますが，control fileでは100%は"100"としています．"volume="の後に"設定値/100"を入れ，以下のコマンドを生成します．

```
ffmpeg.exe -y -i "work\a-san_1280x720_cropped.mp4" -vn -acodec libmp3lame -ar 44100 -ab 256k -af "volume=1.0" "work\a-san.mp3"
```

##4.6. Overlay part
個別に調整した動画を，overlayで1つの動画にします．cropの値から算出して以下のコマンドを生成します．

```
ffmpeg.exe -y -i "work\a-san_1280x720_cropped.mp4" -i "work\b-san_1280x720_cropped.mp4" -filter_complex "overlay=x=640:y=0" -an "work\out_movie.mp4"
```

##4.7. Add audio part
最後に音を全て入力して完成です．mp3のファイル名を配列で保持し，順に-iで繋ぎ，配列のサイズを"amix=inputs="の値として入れて以下のコマンドを生成します．

```
ffmpeg.exe -y -i "work\out_movie.mp4" -i "work\a-san.mp3" -i "work\a-san.mp3" -filter_complex "amix=inputs=2:duration=longest:dropout_transition=2" "out.mp4"
```

この"out.mp4"が完成した動画です．

#5. その他
CUIでコマンドを入力して動画編集する際の大きな欠点として，タイミングやボリュームを見たり聞いたりしながら編集出来ないと言うところがあります．このため，上記の方法で作成した動画は，

* 一部の動画のタイミングを調整したい
* 一部の動画の音量を変更したい

等の要求が出る可能性があります．この時，**調整しない動画は先程の結果を使用して**高速化させるため，control fileに"skip"と言う項目を作成し，これが"on"の場合はその動画の処理をスキップするようにしています．

#6. 終わりに
ffmpegは非常に高機能で使いやすいツールで，自分は以前から重宝していますが，何十，何百とコマンドを打つ，特に動画のサイズ関係の情報を計算して入力するのはかなりの労力が必要です．この記事がその労力を緩和するために役立てば幸いです．