Chienomi

ゲームプレイ動画の動画変換用に分散処理できるスクリプトを書いた

開発::noddy

  • TOP
  • Articles
  • 開発
  • ゲームプレイ動画の動画変換用に分散処理できるスクリプトを書いた

概要

私は結構VOEZのプレイ動画を撮っているのだけど、全部残しておくとかなりの容量になるし、短いスパンで何度もやり直しているような動画を見返すことはないので、結果の出たプレイ動画だけをアーカイブして残すようにしている。

また、オリジナルのデータは20Mbpsを越えるH.264なので、CRF42のVP9に変換している。 だいたいこんな感じ。

ffmpeg -ss 100 -t 120 -i Record_2020-01-01-00-00-00_81285ba1b861b33b22075aa0026ad08f.mp4 -c:v libvpx-vp9 -crf 42 -c:a libopus ../Play/20200101-xxxxx-spl-fc-hs.webm

ただ、libvpx-vp9だと3700Xでも15FPSくらい、4114だと7FPSくらいなので、すごく時間がかかるし、終わるのを確認してから次のコマンドを入れてると果てしない。 かといって並列でバンバン入れられるわけでもない(8コアの3700Xだと3並列くらいが適当。それでCPU usageとしては80%くらい)。

でもまぁ、時間がかかるので並列にはしたいし、それだけじゃなくそこそこパワーのある4114マシンも活用したい、つまり「ネットワーク分散処理可能なようにしたい」と考えたのだ。

いつもの「楽をするためならなんでもやる」姿勢である。

これはマシンで分散処理したいものであり、キューによって分散が可能なものなら幅広く応用が効く手法だ。

基本設計

ファイル自体はSSHFSでアクセスできるようになるけれど、SSHFS経由だとflockすら効かない。 もちろん、Unixドメインソケットも使えない。

シングルキューな設計になっていればよく、単純にはTCPサーバーであれば良い。 リストを読み込んで、それをTCPで待ち受け、1個ずつ応答するような設計にすればキュー部分がシングルスレッドになり、並列実行が可能だ。

しかしこれだと、予め用意したものしか応答できず、リロードできるようにしようとすると色々難しい。 「その瞬間に同時になったらどうするか」などと考えるとかなり厳しい。

そこで、私お得意のRindaを採用することにした。

スクリプト

Rindaサーバー

Rindaサーバー自体は書き方は常にほとんど同じ。 これがtuplespaceを提供する。

セキュリティの都合上、アドレスは変更してある。

#!/usr/bin/ruby

require 'drb/drb'
require 'rinda/tuplespace'

SERVER_ADDRESS = "druby://192.168.100.100:10000"

DRb.start_service(SERVER_ADDRESS, Rinda::TupleSpace.new)
puts DRb.uri
DRb.thread.join

リスト作成者

リスト自体はこんな感じでファイルリストを作り

print -l files1/*.mp4 > list1

エディタでタブ区切りでこんな感じのエントリを作る。

Record_2020-10-20-23-45-49_81285ba1b861b33b22075aa0026ad08f.mp4 -   -   masquerade-hard-ap-firstplay

第2フィールドは開始時間、第3フィールドは終了時間である。 -ssは秒にしないといけないし、-tは秒にした上で-ssを引かないといけないので、それがなくなり楽になる。

第4フィールドは基本的なルールとして

曲_名-難易度-タグ

となっている。

で、このリストを読み込んでtuplespaceにぶちこむスクリプトがこちら。

#!/usr/bin/ruby

require 'drb/drb'
require 'rinda/rinda'

SERVER_ADDRESS = "druby://192.168.100.100:10000"

DRb.start_service
ts = Rinda::TupleSpaceProxy.new(DRbObject.new(nil, SERVER_ADDRESS))

File.foreach(ARGV[0]) do |line|
  next unless line
  next if line =~ /^\s*$/
  c = line.chomp.split("\t")
  ss = nil
  t = nil
  if c[1] =~ /(\d+):(\d+)/
    ss = $1.to_i * 60 + $2.to_i
  end
  if c[2] =~ /(\d+):(\d+)/
    if ss
      t = ( $1.to_i * 60 + $2.to_i ) - ss
    else
      t = ( $1.to_i * 60 + $2.to_i )
    end
  end

  dateprefix = ""
  if c[0] =~ /Record_(\d+)-(\d+)-(\d+)/
    dateprefix = $1 + $2 + $3
  end

  ts.write(["video", {
    ss,
    t,
    c[0],
    "../Play/#{dateprefix}-#{c[3]}.webm"
  }])
end

このスクリプトは単にファイル情報を作ってtuplespaceに書き込んでいくだけなので一瞬で終わる。

リスト消費者

tuplespaceからファイル情報を受け取り、ffmpegを実行するスクリプトがこちら。

#!/usr/bin/ruby

require 'drb/drb'
require 'rinda/rinda'

SERVER_ADDRESS = "druby://192.168.100.100:10000"

DRb.start_service
ts = Rinda::TupleSpaceProxy.new(DRbObject.new(nil, SERVER_ADDRESS))

while entry = ts.take(["video", nil])
  begin
    command = []
    command.concat(["-ss", entry[1][].to_s]) if entry[1][]
    command.concat(["-t", entry[1][].to_s]) if entry[1][]
    command.concat(["-i", entry[1][]])
    command.concat(["-c:v", "libvpx-vp9", "-r", "60", "-crf", "42", "-c:a", "libopus", "-b:a", "128k", entry[1][]])
    puts "***GOING FFMPEG : ffmpeg #{command.join(" ")}"
    system("ffmpeg", *command)
  rescue
    puts "***!!!!!: #{entry.inspect}"
    raise
  end
end

実行時間は非常に長いが、内容自体はシンプルだ。

実行手順

Rindaサーバーのスタート

Rindaサーバーを実行するのは、データをローカルに持っているホストが良い。

なお、アドレスについては私はマシンが固定IPアドレスになっているのでIPアドレスで指定しているが、 ホスト名をちゃんとつけた上でZeroconfを使うほうが良いと思う。

リストの入力

リストを作ったらプロデューサースクリプトを実行し、リストの内容をRindaサーバーに入れる。

これは一瞬で終わるので、並列で実行する意味はない。

必ずしもプロデューサースクリプトの実行を「先に」やる必要はなく、またコンシュマースクリプトが走っているときに実行しても良い。

つまり、コンシュマースクリプトを走らせておいて、追加のファイルリストを作って反映する、といったことができる。

ffmpegでの変換

コンシュマースクリプトを実行すると自動的に対象ファイルの変換が行われる。

コンシュマースクリプトはいくつ同時に実行したとしても支障はなく、単純に並列で変換が行われる。 このスクリプトは「どこで」実行するかに制約があるため、対象のディレクトリをファイルマネージャーで開いて(私の場合Nemo)、「ここで端末を開く」からのスクリプト実行ということになる。

この実行はリモートホストからでもできる。 SSHFSでマウントして、対象ディレクトリをファイルマネージャーで開いたら後は同じ手順だ。 ちなみに、gvfsでも問題なく実行できるので、NautilusやNemoで接続して端末を開いてもできるが、パフォーマンス的にも安定性的にも推奨はできない。

これによってタイミングを気にしない、ネットワーク分散を含めた並列処理が可能になっている。

中断

中断したい場合、Rindaサーバーを止めれば今処理中のファイルが終わったタイミングでコンシュマースクリプトの実行が止まる。 リストのどれが実行されたのかが分からないため次回実行時に少し困るが、この方法だと途中で終わってしまったファイルは発生しないので、コンシュマースクリプトの

command = []

command = ["-n"]

にして実行すれば問題なく再開できる。

メタデータ用スクリプト

さらに私はこれをウェブページからよりわかりやすい形で(つまり、タグ、曲名、難易度などでソートしたり検索して)観られるようにしたいと考えた。 この情報はファイルから得ることはできないが、既に規則性をもって命名されているし、ファイルをもとにして少し手入力すればいけるだろう。 そしてデータベース化すればあとは簡単にページは生成できるはずだ。

とりあえず空のデータベースを作る。

videos: {}

そしてファイルリストからある程度情報を抽出してエントリを埋めていく。 手書きしたものは上書きしたくないので、登録済みファイルは避ける。

#!/usr/bin/ruby

require 'yaml'

meta = YAML.load(File.read("meta.yaml"))

videos = Dir.children("Play")

tags_map = {
  "hs" => "highscore"
}

videos.each do |filename|
  if filename =~ /([^-]+)-([^-]+)-(.*)\.[a-z0-9]/
    next if meta["videos"].key?(filename)
    date = $1
    title = $2
    tags = $3.split("-").reject {|i| i =~ /^\d+$/ }
    title = title.gsub("_", " ")
    level = nil
    if ind = tags.index("hard")
      level = "hard"
      tags.delete_at(ind)
    elsif ind = tags.index("spl")
      level = "special"
      tags.delete_at(ind)
    elsif ind = tags.index("ez")
      level = "easy"
      tags.delete_at(ind)
    end
    date = date[0,4] + "-" + date[4,2] + "-" + date[6,2]
    meta["videos"][filename ] = {
      "title" => title,
      "date" => date,
      "tags" => tags.map {|i| tags_map[i] || i},
      "level" => level,
      "diff" => 0,
      "auto" => true
    }
  elsif filename =~ /([^-]+)-(.*)\.[a-z0-9]/
    date = $1
    date = date[0,4] + "-" + date[4,2] + "-" + date[6,2]
    tags = $2.split("-").reject {|i| i =~ /^\d+$/ }
    meta["videos"][filename] = {
      "title" => "Play through",
      "date" => date,
      "tags" => tags.map {|i| tags_map[i] || i},
      "level" => "mixed",
      "diff" => 0,
      "auto" => true
      }
  end
end

File.open("meta.yaml", "w") {|f| YAML.dump(meta, f)}

これでこんな感じのエントリができる。

videos:
  20200817-lv0-spl-fc.mp4:
    title: lv0
    date: '2020-08-17'
    tags:
    - fc
    level: special
    diff: 0

あとは少し編集するだけだ。

videos:
  20200817-lv0-spl-fc.mp4:
    title: Lv.0
    date: '2020-08-17'
    tags:
    - fc
    level: special
    diff: 13

Markdownテーブルを作るならeRubyでこんな感じ。

|title|date|difficulty|level|tags|
|----------|------|----|--|------|
% videos.each do |k, v|
|[<%= v["title"] %>](videos/<%= k %>)>|<%= v["date] %>|<%= v["level"] %>|<%= v["diff"] %>|<%= v["tags"].join(", ") %>
% end