Chienomi

【Daily hack】未処理の動画ファイルを検出してひとつのライブラリに収める

Live With Linux::dailyhack

  • TOP
  • Articles
  • Live With Linux
  • 【Daily hack】未処理の動画ファイルを検出してひとつのライブラリに収める

目の前の問題を解決する小さなプログラムを書いたり、Linuxテクニックを駆使したりといったことは非常に日常的に行っていることなのだが、すぐ忘れてしまうため、どうしても例示しようとしても出てこないということが多い。

そこで、できるだけそのようなトライを形に残すことで参考になるようにしたいと思う。

今回は、カメラによるメディア(写真・動画)の全変換の最終段階におけるものである。

動画として撮られたもの(例えばビデオカメラで撮影したもの)はvideoディレクトリの下にあるが、普通にカメラ(例えばスマートフォン)で撮影されたものはimageディレクトリの下にある。 今回の作業は

  1. JPEGの写真をAVIFに変換/サムネイルを生成
  2. 混在している動画ファイルを分離してVP9/OpusのWebMに変換

という形で圧縮/再編するというものである。 前者に関しては入念に行ったのだが、後者については方針が定まらない状態で着手した上に途中でミスがあったため、変換できていないファイルがないかの確認をすると共に、抜けがあれば変換を行うという対応を行った。

方針を考える

もともとのソースディレクトリ、一度退避したソースディレクトリ、ソースディレクトリの元になったもともとのアーカイブ、最近アップデートしたアーカイブの4つに分かれている。これらをソースとして完全な写真フォルダと動画フォルダを完成させようとしている。

ソースディレクトリがアーカイブと異なるものであるのは、新しい写真フォルダを構成するために、バラバラのフォルダ名や階層の構造を統一するために、一旦コピーして調整したためだ。

これが問題を複雑にもしていて、ファイルパスを基準にすると、同じファイルが異なるフォルダ構成で存在するために重複したファイルが変換されてしまう。 基本的にカメラはデータを取り出しても同じファイル名でファイルを生成することはない(アクションカメラなどは別だが)ため、ファイル名だけを考えることにする。

例えばオリジナルアーカイブが

moto/mt09/video0001.MOV

ソースディレクトリが

20150525-mt09/video0001.MOV

変換後ファイルが

20150525-mt09/videeo0001.webm

となるが、ふたつのソースファイルが同じもので、変換済みであると判定できなければならない。 これは、ファイル名のみを拡張子も除いて抽出することで

video0001

とすれば判定可能だ。

フォルダ名を抽出する必要がある関係で、アーカイブよりもソースディレクトリが優先されなければならない。

変換については並列で行いたいため、工夫が必要だ。 その目的で作られたmmffrとmmfft9があるが、今回はうまくフィットしない。 変換漏れなどが発生しないようにかなり高度なキューシステムが必要となるが、並列度がそこまで高くないことを考えてファイルに情報を書き、flockを使う方針とした。

最初はうまくコマンドやワンライナーでがんばろうとしていたが、Rubyで書いてしまったほうが楽だと判断してワンタイムなプログラムを書いた。

キューファイルを作る

(ソース集合) - (変換済みファイル群) によって変換されたファイルに見当たらないファイルを列挙し、そのまま処理に渡すことができるキューJSONを生成して出力する。

#!/usr/bin/ruby
# -*- mode: ruby; coding: UTF-8 -*-
require 'find'
require 'oj'

converted = {}
sources = {}

Find.find("Play") do |i|
  next unless i =~ /\.webm$/
  converted[File.basename i, ".*"] = true
end

ARGV.each do |base|
  Find.find(base) do |i|
    filename = File.basename i, ".*"
    next if sources[filename]
    next if filename[0] == "."
    next unless i =~ /\.(?:webm|mov|mkv|mp4|avi|3gp|3gg|3g2|3gp2)$/i
    sources[filename] = i
  end
end

converted_list = converted.keys
sources_list = sources.keys

diff_list = sources_list - converted_list

obj = {
  "queue" => diff_list.map {|i| sources[i] },
  "processing" => {},
  "failed" => [],
  "finished" => [],
  "skipped" => {}
}

STDOUT.puts Oj.dump obj

File.findによってそれぞれのディレクトリをさらい、拡張子で対象のファイルを抽出して全リストをつくる。 変換対象になった場合はソースファイルのフルパスが必要であることに留意する。

既に拾ったことがあるファイル名をスキップすることで、ARGVに優先するものを先に書けるようにした。

このスクリプトは最終的にJSONを出力するだけである。 変換プログラムのほうがこのJSONを利用する。そのため、JSONは単なるファイルのリストではなく、キューデータの形式として出力する。

Ruby標準のjsonライブラリもあるが、私の手元にはより高速なoj gemがインストールされているため、Ojを使っている。

変換を行う

#!/bun/ruby
require 'oj'

def update
  File.open("videofiles.json", "r+") do |f|
    f.flock(File::LOCK_EX)
    begin
      object = Oj.load(f)
      yield(object)
      f.seek(0)
      f.truncate(0)
      f.write Oj.dump(object)
    ensure
      f.flock(File::LOCK_EX)
    end
  end
end

loop do

  source_file = nil

  update do |db|
    source_file = db["queue"].shift
    break unless source_file
    db["processing"][source_file] = $$
  end

  break unless source_file
  
  dir = File.basename(File.dirname(source_file))
  file = File.basename(source_file, ".*")

  unless File.exist? ["Play", dir].join("/")
    Dir.mkdir ["Play", dir].join("/")
  end

  if source_file[-5, 5] == ".webm"
    update do |db|
      db["processing"].delete source_file
      db[source_file] = ["Play", dir, (file + ".webm")].join("/")
    end
  end

  begin
    system("ffmpeg", "-i", source_file, "-c:v", "libvpx-vp9", "-crf", "35", "-b:v", "20000k", "-c:a", "libopus", "-b:a", "192k", ["Play", dir, (file + ".webm")].join("/"))
    unless $?.success?
      raise
    end
  rescue
    update do |db|
      db["processing"].delete source_file
      db["failed"].push source_file
    end
  else
    update do |db|
      db["processing"].delete source_file
      db["finished"].push source_file
    end
  end
end

JSONファイルを共有キューとして使うため、

  1. JSONファイルをオープン
  2. flock
  3. ファイルをリード
  4. JSONをparse
  5. オブジェクトを更新
  6. ファイルをクリア
  7. JSONを書き込み
  8. flock解除

という処理が高い頻度で入る。

Ruby標準のMarshalを使う場合はPStoreが、YAMLを使う場合はYAML::Storeがあるが、JSONにはないため自分で書く必要がある。 素直にYAMLを使ったほうが楽だったという気もするが、大した手間ではない。 自前でupdateメソッドを用意する。 「オブジェクトを更新」の手順が動的だが、Rubyの場合は「ブロックを渡す」ということが可能なので、yieldで簡単に実現可能。

ソース側がWebMであった場合は「変換済みファイルがアップされている」状態なので、ハードリンクするのが正しい。 この手順では処理できないため、ハードリンクすべき対応関係を残すようにする。

エラー発生時はエラーが発生したファイルを残すようにして、手動リカバリーを可能にする。

mmfft9ほど強力な並列処理が可能なわけではないが、結構実用的だ。

スナップショット

容量は圧縮したいが、ドライブ容量が現時点で逼迫しているわけではない。 かなり強く破壊的な変更であるため、Btrfsでスナップショットを取る。 普段はタイムスタンプでスナップショットを取っているだけだが、今回はわかるようにする。

btrfs subvolume snapshot -r main 20230321-230000-USER_MEDIA_ORIGINAL_FINAL

実際にディスク容量が空くのは、ここまでのスナップショットをすべて削除したときである。