Chienomi

【実例学習】再帰的に大きすぎる動画ファイルを探し、ハードウェア圧縮する

プログラミング::learnin

目的

あるディレクトリ以下に動画ファイルが存在するが、一部動画ファイルが意図せぬ大きなファイルになっている。 この動画ファイルが大きい理由はビットレートが異常に高い、もしくはビデオサイズが大きいことに由来する。

今後に関しては生成時に大きくならないようにパラメータ調整をすれば良いわけだが、既に生成されてしまった大きい動画をそのままにするとストレージを圧迫してしまうので圧縮したい。

この動画は記録的要素が大きく、高品質であることは重要ではない。

前提

  • Linuxである
    • av1_vaapiを使っているので基本的にLinux(あるいはFreeBSD, OpenBSD, DragonflyBSD, NetBSD)でしか動作しない。ハードウェアエンコード用コーデックやパラメータを変更すれば他のOSでも動作しうる
  • AV1デコード/エンコードに対応しているビデオカードを使用している
    • av1_vaapiを使用するため。 h264_vaapiを使用するなどして要件を緩和できる
  • 出力の細かなクオリティ面には妥協できる
  • ソースディレクトリはリモートホスト上にある
    • SSHFSとrsyncの使用がこの要件を満たすためだけど、別にSSHFSを使わずにローカルファイルシステム上のディレクトリを指定して、rsyncも同様にしても動作する

コード

Codebergで見る

#!/bin/env ruby
require 'find'
require 'fileutils'
require 'json'

outdir = "/path/to/outdir"
exclude_keyword = %w[foo bar]

Find.find(ARGV[0]) do |fp|
  next unless File.extname(fp) == ".webm"
  meta = nil
  IO.popen([*%w!ffprobe -v error -select_streams v:0 -show_entries stream=width,height:format=bit_rate -of json!, fp]) do |io|
    meta = JSON.load io
  end
  next unless meta["format"]["bit_rate"].to_i > 6000000
  next if exclude_keyword.any? {|i| File.basename(fp).include? i }

  pe = fp[ARGV[0].length ..].lstrip("/")
  outpath = [outdir, pe].join("/")

  ffargs = [
    "ffmpeg", "-y",
    "-init_hw_device", "vaapi=foo:/dev/dri/renderD128",
    "-hwaccel", "vaapi", "-hwaccel_device", "foo",
    "-hwaccel_output_format", "vaapi",
    "-i", fp,
    "-c:v", "av1_vaapi", "-b:v", "3000k",
    "-c:a", "copy"
  ]
  if meta["streams"][0]["width"].to_i > meta["streams"][0]["height"].to_i && meta["streams"][0]["height"].to_i > 1080
    ffargs.push("-vf")
    ffargs.push("scale_vaapi=-1:1080")
  elsif meta["streams"][0]["width"].to_i < meta["streams"][0]["height"].to_i && meta["streams"][0]["width"].to_i > 1080
    ffargs.push("-vf")
    ffargs.push("scale_vaapi=1080:-1")
  end

  ffargs.push outpath
  FileUtils.mkdir_p(File.dirname outpath) unless File.exist?(File.dirname outpath)

  puts "Process #{fp}"
  system(*ffargs)
end

コード解説

ライブラリロード

# 再帰的に動画を探すために使う
require 'find'
# 出力ディレクトリを用意するmkdir_p用
require 'fileutils'
# ffprobeのJSON出力をパースするため
require 'json'

事前準備変数

# 出力ディレクトリのパス。 / でjoinするので、最後の/はつけない。
outdir = "/path/to/outdir"
# 大きいサイズのままで良い動画を除外するためのキーワード。ファイル名にこれが含まれていたら除外するようにする。
exclude_keyword = %w[foo bar]

ループ

# 第一引数のディレクトリを起点にして再帰的に辿る
Find.find(ARGV[0]) do |fp|
  # ソース動画は.webmだけなので、それ以外は対象外として次のループへ
  # .mp4がある場合はそれも判定に入れる必要がある
  next unless File.extname(fp) == ".webm"
# ...
end

メタデータの読み込みと処理判定

# IO.popen内から値を持ち出したいので外側で定義
meta = nil
# ffprobeを使ってメタデータをJSON形式で出力する。
# `` を使うよりも紛れがないのでおすすめ
IO.popen([*%w!ffprobe -v error -select_streams v:0 -show_entries stream=width,height:format=bit_rate -of json!, fp]) do |io|
  # -of jsonでJSON形式で出力しているのでそのままJSONとしてパースできる
  # JSON.loadは引数にIOオブジェクトを取れるので、そのまま渡す
  meta = JSON.load io
end
# ファイル全体でのビットレートが6000000bps(=6Mbps=6000kbps)を下回っているのであればスキップ
next unless meta["format"]["bit_rate"].to_i > 6000000
# ファイル名が除外キーワードを含んでいるのならスキップ。ffprobeより前に判定すると速くなるが、手順的なわかり易さ重視
next if exclude_keyword.any? {|i| File.basename(fp).include? i }

ffprobeでJSON形式でメタデータを取りたい場合、

ffprobe -v error -select_streams v:0 ... -of json $filepath

が基本形。

-show_entriesが出力したい項目で、stream=width,heightでストリームごとのwidthheightを、format=bit_rateで全体のbit_rateを出力するようにしている。

これで

{
  "streams": [
    {
      "height": "100",
      "width": "100"
    }
  ],
  "format": {
    "bit_rate": "3000000"
  }
}

みたいなものが返ってくる。

注意すべき点として、ffprobeが出力するJSONは、値としては数値であるものでも文字列になっている。

出力の下準備

# ディレクトリのパスから指定したディレクトリをルートとしたパス部分を抜き出す
# 例えば Find.find("/foo") とした場合、そのディレクトリ以下の bar/baz.webm は "/foo/bar/baz.webm" がブロック引数になる。
# ここからFind.findに渡した値を除外したい場合、指定したディレクトリは出力文字列の先頭に位置し、長さは指定した文字列の長さに等しい
# このため、path[dir.length ..]はこれが "/foo" (4文字)である場合インデックス4(5番目の文字)から開始され、 "/bar/baz.webm" になる。
# ただこの場合、単純にprefixを削除すればいいわけではなく、ディレクトリセパレータである / が先頭にきてしまう
# このため、String#lstripを使って先頭の / を削除しておく
pe = fp[ARGV[0].length ..].lstrip("/")
# outdirとパスエレメントを / でjoinすると、ファイルツリーの構造を保った形で出力できる。
# この outpath はffmpegの引数として使われるが、ffmpegは出力するファイルのディレクトリは存在していることを前提とする
outpath = [outdir, pe].join("/")

コマンドの組み立て

# コマンドの可変部分を楽にするために配列にしておく
# 入力ファイルのパスは値は可変だが位置が変わったりはしないので、これを含めてリテラルでまず用意する。
# /dev/dri/renderD128 は環境によって変化する値だが、使い回す予定はないので自分の環境に合わせてハードコーディングしている
ffargs = [
  "ffmpeg", "-y",
  "-init_hw_device", "vaapi=foo:/dev/dri/renderD128",
  "-hwaccel", "vaapi", "-hwaccel_device", "foo",
  "-hwaccel_output_format", "vaapi",
  "-i", fp,
  "-c:v", "av1_vaapi", "-b:v", "3000k",
  "-c:a", "copy"
]

# ビデオサイズが大きすぎる動画のためにスケール(リサイズ)のフィルタをセットする。
# 焦点は2つ。 「短辺が1080pxを越えているか」 と 「高さと幅どちらが短辺か」
# より関心が強いのは1080pxを越えているかどうかだけれども、先に短辺が分かっていたほうが判定は短い
# このため、縦横の判定を先にやってしまう
# この判定だと正方形の動画が漏れるが、今回の場合そんなものはないと分かっているので考慮していない
if meta["streams"][0]["width"].to_i > meta["streams"][0]["height"].to_i && meta["streams"][0]["height"].to_i > 1080
  ffargs.push("-vf")
  # 短辺基準のスケーリング。ここはどちらを基準にするかでアスペクト比次第で結果が変わる
  # ただ判定を短辺基準でやったのだから、短辺基準にしたほうがいい
  # なおav1_vaapiでエンコードすると、ここで指定した通りにはならない場合がある
  ffargs.push("scale_vaapi=-1:1080")
elsif meta["streams"][0]["width"].to_i < meta["streams"][0]["height"].to_i && meta["streams"][0]["width"].to_i > 1080
  ffargs.push("-vf")
  ffargs.push("scale_vaapi=1080:-1")
end

処理へ

# ffmpegの最後の引数である出力ファイルのファイルパス
ffargs.push outpath
# ディレクトリが存在しない場合は用意しておく
FileUtils.mkdir_p(File.dirname outpath) unless File.exist?(File.dirname outpath)

# 進捗確認用にファイル名を出しておく
puts "Process #{fp}"
# ffmpegを実行。systemは引数も展開して渡す
system(*ffargs)

利用

処理ソースがファイルシステムとしてアクセスできる必要があるので、SSHFSでマウント

sshfs remote:Videos /mnt

スクリプトを実行

ruby videotree-av1enc.rb /mnt

正常に処理されたのを確認してソースツリーを上書き

rsync -rv /path/to/outdir/ /mnt/

正常にマージされたのを確認して削除

rm -r /path/to/outdir