RailsでFFmpegを使って配信用動画をエンコードしてみた

# はじめに
この度、動画のstream配信をするアプリケーションにおいて、事前にアップロードした動画を配信する機能開発に取り組んでいます。
その際、色々と考慮すべきケースがあったので、今回記事にまとめてみました。

# 対象読者
- 動画処理・配信をこれから学習してみたい人
- Ruby on RailsのActiveStorageやActiveJobをある程度使ったことがある人
- Dockerを使ったことがある人

# 要件
- 一回でアップロードされる動画は20MB〜40MBのものを想定
- ファイルサイズが大きいので減らしたい
- 出力動画のビットレートは1700kくらいにしたい
- 管理画面から動画のアップロードを行うため、多くのユーザーが同時にアップロードする訳ではない
- サーバーのスペックは可能な限り上げないようにしたい

# エンコードする
エンコードとは、符号化、記号化といった意味で、アナログやデジタルデータを特定のルールに基づいた記号方式のデータに変換することを指します。動画の容量を圧縮したり、動画形式に変換したりすることができます。

https://www.ntt-west.co.jp/business/glossary/words-00732.html#:~:text=%E7%AC%A6%E5%8F%B7%E5%8C%96%E3%80%81%E8%A8%98%E5%8F%B7%E5%8C%96%E3%81%A8%E3%81%84%E3%81%A3%E3%81%9F,%E3%81%99%E3%82%8B%E3%81%A8%E3%81%84%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8%E3%82%82%E3%81%A7%E3%81%8D%E3%82%8B%E3%80%82

つまり、要件を満たすためには、エンコードの処理が必要になります。

# FFmpeg

FFmpegは、動画や音声のエンコード（他にもあるが）を行うためのオープンソースのソフトウェアです。多くのメディア形式をサポートしており、広く一般的に使用されています。
今回はFFmpegを利用して、エンコード処理を行います。

公式サイト

https://www.ffmpeg.org/

Github

https://github.com/FFmpeg/FFmpeg

# RailsでFFmpegを使う

いくつかRailsでFFmpegを利用するためのgemはありますが、今回はメジャーなstreamio-ffmpeg（本家FFmpegのwrapper）を選定しました。
理由は以下です。

- FFMPEG::Movie.newで生成したインスタンスでメタデータを管理できる
- 本家FFmpegはコマンドの引数にoptionを渡す形になるが、streamio-ffmpegはkey/valueで渡せるので見やすい
- エンコードの進捗（progress）を簡単なコードで取得できる

https://github.com/streamio/streamio-ffmpeg

## インストール

まず、FFmpegを入れる必要があります。

私はDockerを使って環境を構築しているためDockerfileに以下を追記し、インストールする形としました。

```Dockerfile
# 他にもinstallしているが、ffmpegのみ抜粋
RUN apt-get install -y --no-install-recommends ffmpeg
```

次にRailsのGemfileに以下追記します。

```ruby
gem 'streamio-ffmpeg'
```


## 処理サンプル

streamio-ffmpegを使ったコードはとてもシンプルです。

例）mov→mp4に変換する場合

```ruby
movie = FFMPEG::Movie.new("path/to/movie.mov")
movie.transcode("tmp/movie.mp4") # Default ffmpeg settings for mp4 format
```

他の諸々の指定はドキュメントを確認ください。

# エンコードの設定
エンコード設定を行うことで、出力される動画のファイルサイズと品質のバランスを設定することができます。
具体的な設定値の詳細については、FFmpegのエンコーディングガイドを見るのが良いでしょう。

https://trac.ffmpeg.org/wiki/Encode/H.264

ここでは、それぞれの主要な設定値の種類と、設定値の選定理由について簡単に記載します。

- コーデック
- Constant Rate Factor (CRF)
- プリセット
- ビットレート

## コーデック
コーデックと呼ばれる動画圧縮技術を用いて、大きなサイズの動画データを圧縮して、配信や保管することができます。

ここでは詳細は割愛しますが、わかりやすく説明されているサイトを掲載致します。

https://www.dpsj.co.jp/tech-articles/video-codecs-encoding

主要なものはH.264 コーデックです。
後継規格のH.265 は、H.264 に比べて圧縮効率が高く、同じ画質でより小さなファイルサイズを生成できるコーデックとの事です。
ただ、実際にエンコードしてみたところ、サーバーのメモリ負荷・CPU負荷が大きく増加したため、今回H.265コーデックへの変換は行わないこととします。

streamio-ffmpegを用いてエンコードする場合、デフォルトでH.264 コーデックが適応されますので、これに従います。

## Constant Rate Factor (CRF)
エンコーダーが特定のフレームに費やすビット数を決定するときに行う処理のことを「レート制御」と言います。
レート制御を行うための手法（レート制御モード）がいくつかありますが、そのうちの一つのがCRFです。
この設定をすることにより、ファイルサイズと品質の分配方法が決まります。
ファイルサイズを小さくすることよりも品質維持を重視したい場合、このレート制御モードを使用します。
最も推奨されるレート制御モードであると記載されています。

:::note warn
FFmpegのドキュメントによると、特定のファイルサイズまたはビットレート値の指定はできないため、ストリーミング用の動画のエンコードには推奨されないようです。
:::

今回、以下理由によりCRFの設定は行わないこととします。
- ストリーミング用の動画をエンコードする
- ビットレート1700kにしたい（特定のビットレート値の指定をしたい）

## プリセット
プリセットを指定することで、エンコード速度と圧縮率のバランスを設定することができます。
速度の降順で利用可能なプリセットは次のとおりです。

- ultrafast
- superfast
- veryfast
- faster
- fast
- medium – デフォルト
- slow
- slower
- veryslow

veryslowは最も圧縮率が高いですが、最も時間がかかるという訳です。

### medium検証
- ファイルサイズは20MB→1.2MB
- エンコードにかかる時間は10秒くらい
- サーバーのメモリ負荷・CPU負荷が急増した

### ultrafast検証
- ファイルサイズは20MB→1.3MB
- エンコードにかかる時間は5秒くらい
- サーバーのメモリ負荷の増加は見受けられなかったがCPU負荷は増加した

グラフを載せておきます。
![スクリーンショット 2024-11-29 8.04.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730383/a9ccb779-cb60-9f05-39ff-13db9b189fc5.png)
medium　7:20
ultrafast　7:50

今回結果として、出力動画のファイルサイズがほぼ変わらない上に、エンコード時間が短く、メモリ負荷の点でもメリットがあるultrafastに決定しました。

```ruby
movie.transcode(output_file_path.to_s, x264_preset: 'ultrafast')
```


## ビットレート
1秒間あたりのデータ量のことをビットレートと呼びます。単位にはbpsを使います。

今回、ビットレート1700kにしたいため、ビットレートの設定を行います。


```ruby
movie.transcode(output_file_path.to_s, video_bitrate: '1700k', x264_preset: 'ultrafast')
```

メモリ負荷・CPU負荷の結果は、プリセット検証のグラフのultrafast　7:50の通りとなります。

# エンコードの進捗を取得する

ドキュメントによると、こんなに簡単に進捗を取得できるようです。

```ruby
movie.transcode("movie.mp4") { |progress| puts progress } # 0.2 ... 0.5 ... 1.0
```

値は、%かつ小数点無しで管理したいので、以下の記載としました。

```ruby
movie.transcode(output_file_path.to_s, video_bitrate: '1700k', x264_preset: 'ultrafast') do |progress|
  delivery_video.update!(progress: (progress * 100).round(2))
end
```

progressカラムの値を更新してくれるため、フロントエンド側から1秒置きくらいでポーリングすると、いい感じに画面側で%表記ができるようになります。


# 非同期処理

エンコードは処理が重く、非常に時間がかかりますので以下の問題があります。

- 同時に複数のエンコード処理を行うとサーバーに大きな負荷がかかってしまう
- 処理に時間がかかるため、フロントエンド側にレスポンスを返すのが遅くなってしまい、ユーザー体験が悪い

なので非同期処理化し、バックグランドで処理を実行するのが良いでしょう。

RailsではActiveJobという仕組みを用いることでバックグラウンドジョブを実現できます。

https://railsguides.jp/active_job_basics.html


また、今回同時に複数の処理を行わずに一つずつ順番に処理する形としたいため、今回worker数は1で運用します。RailsのActiveJobではデフォルトのworker数は1なので、特別な指定は必要ないでしょう。

コードは以下となります。

```delivery_video_controller.rb
def create
  delivery_video = DeliveryVideo.new

  begin
    # jobの引数に大きいデータを渡したくないため、一旦attachしておく
    delivery_video.save!
    delivery_video.video.attach(params[:video])

    # job内で動画インスタンス&動画を取得する形としたいため、idだけ渡す
    VideoCompressionJob.perform_later(delivery_video.id)
    render json: { delivery_video_id: delivery_video.id, message: 'Delivery video is being processed' }
  rescue => e # 本当は例外クラス毎に分けているが、今回省略
    render json: { error: e.message }, status: :unprocessable_entity
  end
end
```

↓こちらは処理を追いやすくするため、細かくメソッド分割していません

```video_compression_job.rb
def perform(delivery_video_id)
  # データベースから動画インスタンス取得
  delivery_video = DeliveryVideo.find(delivery_video_id)

  # blobをダウンロードする
  video_data = delivery_video.video.download

  input_file_path = Rails.root.join('tmp', 'uploaded_video.mp4')
  output_file_path = Rails.root.join('tmp', 'compressed_video.mp4')

  # 後にtranscodeするために一旦保存
  File.open(input_file_path, 'wb') do |file|
    file.write(video_data)
  end

  # FFmpegインスタンス作成
  movie = FFMPEG::Movie.new(input_file_path.to_s)

  # 変換処理
  movie.transcode(output_file_path.to_s, video_bitrate: '1700k', x264_preset: 'ultrafast') do |progress|
    delivery_video.update!(progress: (progress * 100).round(2))
  end

  # ファイル読み込み
  compressed_video = File.read(output_file_path)

  # 保存する
  delivery_video.video.attach(compressed_video)
    
  # 一時ファイルは削除しておく
  File.delete(input_file_path) if File.exist?(input_file_path)
  File.delete(output_file_path) if File.exist?(output_file_path)
end
```

## なぜJobを実行する前に動画をattachして保存しておくのか？

一点補足です。
なぜJobを実行する前に一時的に動画をattachして保存しておくのか？という話ですが、Jobを実行する際に渡す引数のデータが大きすぎると、Job実行時にかかる負荷が大きくなるためです。

例えばActiveJobで扱えるJob実行機能のDelayedJobは、データベースでキューを管理するシステムですが、以下のようなエラーが出る場合があります。

```
Failed enqueuing VideoCompressionJob to DelayedJob(default): ActiveRecord::ValueTooLong (Mysql2::Error: Data too long for column 'handler' at row 1)
```

このエラーメッセージは、DelayedJobのhandlerカラムに保存しようとしているデータが、カラムの最大長を超えていることを示しています。これは、Jobの引数が大きすぎる場合に発生します。

Redisなどのキャッシュサーバーを使ったキューイングシステムであるSidekiqなどの他のJobシステムにおいても同様に、Job実行の引数に渡すデータ量は多くない方が良いでしょう。

なので解決策として、

- Job引数には動画保存に関わるレコードを取得するためのidだけ渡す形とする
- 「Jobの中で」予め保存された動画をダウンロードする
- その後、エンコード処理に移っていく

という流れが、Job実行時にかかる負荷を削減するアプローチとなります。

# 最終的な画面

画面側のサンプルはこんな感じです。
動画をアップロードする処理の間はローディングを回し、エンコード処理は%表記する形としています。

エンコード処理を非同期処理化することで、
動画のアップロードが終わったタイミングでフロントエンド側に早くレスポンスを返すことができるようになりました。

結果として、エンコード処理の間画面をロックし続ける必要がなくなったのと、いつまで待てば良いんだろう？といった気持ちにならなくて済むので、ユーザー体験を良くすることができます。


![画面収録 2024年11月29日 (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730383/4102cdd1-57fc-c29f-d3b4-e36d065266ae.gif)


# さいごに
割と基本的な内容かな？という気はしますが、やってみるとまだまだ動画処理・配信に関する技術的な知見が足りないので吸収していく必要があると感じます。この分野結構面白いなと感じているので、引き続きキャッチアップしていきたい気持ちです。