FFmpegを使用した動画変換処理で発生した問題の対応策

こんにちは、MERY開発部のhoshinoです。

MERYのサービスの裏側では、多数のアプリケーション*1MERYのシステム概要: https://mery.dev/616が稼働しています。障害などの問題が発生した場合は、社内のエンジニアが全て対応をしています。

サービス開始前からエンジニアの人数が少なかったこともあり、私はサーバーサイド全体の障害対応をする機会が多くありました。他のエンジニアが書いたコードで発生したさまざまな障害対応を経験することで、起きている事象やエラーメッセージから瞬時に仮説を立て、アプリケーション内のどこでどのようなことが原因でエラーが発生しているのかを早期に特定できるようになりました。しかし、経験や知識がないまま、いざ一人で対応することになった時には、原因の特定までに時間を要することになり、影響範囲の拡大につながる恐れもあります。

私がMERYで行ってきた障害対応などの中から、原因特定までに時間がかかったり、気が付きにくい落とし穴のあった問題の対応策を実例を通して、隔週3回連続で紹介します。

1回目となる今回は、FFmpegを使用した動画変換処理で発生した問題の対応策*2紹介する内容は、技術書典6で頒布されたINSIDE MERY [6.3]と同じ内容です。について紹介します。

MERYでは、動画の保存にPaperclip + Paperclip Transcoder + FFmpegを使用していて、保存時には元動画以外に2種類のサイズの動画を生成しています。これら3種類の動画は、回線環境(Wi-Fiや4Gなど)に応じてサイズを切り替えて使用されています。

ここで紹介するのは、一部の記事において元動画以外の動画が保存されておらず、動画が表示された時に自動再生されない状態になった問題についてです。

リサイズ後のサイズに奇数の値があることで発生する問題

原因の調査をした時はリスト1のような設定になっていました。

class Video < ActiveRecord::Base
  has_attached_file :video,
    styles: {
      thumb: {
        format: :jpg,
        time: 0
      },
      mobile: {
        format: 'mp4',
        convert_options: {
          output: {
            :'c:v' => 'libx264',
            :'profile:v' => 'baseline',
            :'level:v' => '3.1',
            :movflags => 'faststart',
            :vf => 'scale=640:-1',
            :'b:v' => '500k',
            :r => 30
          }
        }
      },
      low: {
        format: 'mp4',
        convert_options: {
          output: {
            :'c:v' => 'libx264',
            :'profile:v' => 'baseline',
            :'level:v' => '3.1',
            :movflags => 'faststart',
            :vf => 'scale=640:-1',
            :'b:v' => '500k',
            :r => 30
          }
        }
    },
    :processors => [:transcoder]

  validates_attachment_content_type :video,
    allow_blank: true,
    content_type: ['video/mp4'],
    message: 'MP4のみアップロードできます'
end

問題のある動画を開発環境で変換してログを確認したところ、エラー情報がログに出力されていました。しかし、後続処理が継続して実行され、何もなかったかのように呼び出した処理が正常終了した状態になりました。

動画変換が実行されるところでは、リスト2のように例外をキャッチして、Slackにエラー通知が送信されるようになっていました。

video_url = 'https://example.com/sample.mp4'

begin
  video = Video.new
  video.original_url = video_url
  video.video = URI.parse(video_url)
  video.save
rescue => e
  message = "動画変換に失敗しました (#{e.message})"
  slack_notification(message) # Slackへエラー通知を送信する
end

しかし、エラー通知がきたことは一度もなく、例外をキャッチできていないことは明らかです。そのため、リスト3のように動画変換で例外を発生させて、rescue Exception => eで全ての例外クラスをキャッチし、問題となる例外クラスを調べると、Av::CommandErrorであることがわかりました。

begin
  # 動画変換でエラーを発生させる
rescue Exception => e
  puts "Error: class => #{e.class}"
end

# Error: class => Av::CommandError

Paperclip Transcoderでは、FFmpegのコマンド実行にAVを使用していて、変換処理でエラーが発生した場合の例外クラスはAv::CommandErrorであり、この例外クラスはExceptionを継承しています。そして、動画変換処理のrescueではStandardErrorしか指定されていないので、例外をキャッチできていなかったことがわかります。

エラー通知がきていなかった原因がわかったところで、その次は動画変換時にログに出力されたエラー情報について調べました。

リスト4のコマンドでは、同じ動画を使用してリサイズ後のheightが奇数になる場合とそうでない場合に分けて、変換処理を実行しています。リサイズ後のheightが奇数にならない場合だけ、「height not divisible by 2」というエラーメッセージが出力されています。このことから、動画をリサイズする場合において、リサイズ後のサイズの値に奇数の値があることで、FFmpegでリスト4のように変換エラーが発生することがわかりました。

# 比率を維持したままwidthが600になるように変換
$ ffmpeg -i input.mp4 -vf scale=600:-1 output.mp4
…
Output #0, mp4, to '/Users/example/output.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf57.83.100
    Stream #0:0(und): Video: h264 (libx264) (avc1 / 0x31637661),
    yuv420p, 600x650 [SAR 325:198 DAR 50:33],
    q=-1--1, 60 fps, 15360 tbn,60 tbc (default)
…

# 比率を維持したままwidthが640になるように変換
$ ffmpeg -i input.mp4 -vf scale=640:-1 output.mp4
…
[libx264 @ 0x7f8688808c00] height not divisible by 2 (640x693)
Error initializing output stream 0:0
-- Error while opening encoder for output stream #0:0
- maybe incorrect parameters such as bit_rate, rate, width or height

FFmpegをあまり使用したことがなく、事前にこのことを知らない多くの方は、この落とし穴に引っかかってしまうのではないでしょうか。

対応策(リサイズ後のサイズが奇数になる動画を使用できないようにする)

rescueで指定する例外クラスが間違っていることでエラー通知が送信されなかった問題については、指定する例外クラスが間違っているだけであるため、リスト5のように追加すれば変換処理でエラーが発生しても例外をキャッチできます。

begin
  …
rescue StandardError, Av::CommandError => e
  …
end

リサイズ後のサイズに奇数の値があることで発生する問題については、エラーメッセージに書かれているとおりに、リサイズ後のwidthとheightの両方の値が偶数になっていればよいだけです。ただし、全ての動画でうまくサイズが合うようにリサイズするというのは困難なため、状況に応じて次の2種類の対応方法があります。

  1. リサイズ後のwidthとheightの両方の値が偶数であることを確認するValidatorを追加して、変換時にエラーが発生する動画が保存されないように弾く
  2. 変換時に元動画のサイズを取得して、リサイズ後の値を算出して奇数になるようであれば、偶数になるように値を丸めて、その値を変換後のサイズとして指定する

上記の2つ目の対応では、偶数になるように値を丸めているので、比率を維持せずに最大1pxずらすことになり、人の目ではわからなくても、動画に歪みが生じてしまうことになります。そのため、保存済みの動画のみ2つ目の方法で対応することにしました。その際に使用したFFmpegのコマンドは、リスト6*3参考文献: https://qiita.com/genchi-jin/items/90078b6ec751fdacbc9eのようになります。このコマンドを使用して手動で変換し、出力された動画を元動画に上書きすることで対応しました。

# Paperclipから実行されていたコマンドで変換
$ ffmpeg -i input.mp4 -c:v libx264 -profile:v baseline -level:v 3.1
-movflags faststart -vf scale=640:-1 -b:v 500k -r 30 output.mp4
…
[libx264 @ 0x7f93db015e00] height not divisible by 2 (640x693)
Error initializing output stream 0:0
-- Error while opening encoder for output stream #0:0
- maybe incorrect parameters such as bit_rate, rate, width or height

# 記事の最後に、簡単に偶数にする方法を追記しましたので、そちらもご参照ください。
# scaleの設定を変更して変換
$ ffmpeg -i input.mp4 -c:v libx264 -profile:v baseline -level:v 3.1
-movflags faststart -vf "scale=640:trunc(ih/(iw/640)/2)*2" -b:v 500k
-r 30 output.mp4
…
Output #0, mp4, to '/Users/example/output.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf57.83.100
    Stream #0:0(und): Video: h264 (libx264) (avc1 / 0x31637661),
    yuv420p, 640x692 [SAR 865:528 DAR 50:33],
    q=-1--1, 500 kb/s, 30 fps, 15360 tbn, 30 tbc (default)
…

一方、新しく保存される動画については、リスト7のようにValidatorを追加して弾くようにして、保存できない場合は元動画のサイズを変換してから保存してもらうようにしました。

class ResizeVideoValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    ffprobe = Paperclip.run(
      "ffprobe",
      '-i %s -of json -hide_banner -show_entries stream=width,height' % value
    )
    resolution = JSON.parse(ffprobe)
    streams = resolution['streams'].reject(&:blank?)[0]
    width = streams['width'].to_f
    height = streams['height'].to_f
    long_side_name, short_side_name, size =
      if width >= height
        [
          'width',
          'height',
          (height*(Video::WIDTH/width)).round
        ]
      else
        [
          'height',
          'width',
          (width*(Video::HEIGHT/height)).round
        ]
      end

    unless size % 2 == 0
      record.errors[attribute] <<
        "で指定された動画は、リサイズ後(#{long_side_name}: " \
        "#{('Video::' + long_side_name.upcase).constantize}px)の" \
        "#{short_side_name}が2で割り切れない数値(#{size}px)です。" \
        "リサイズ後の#{short_side_name}が2で割り切れるように" \
        "動画サイズを変更してください。"
    end
  end
end

class Video < ActiveRecord::Base
  has_attached_file :video,
  …

  # MERYでは、URLから動画を保存する場合は、original_urlにURLを保存しているので、
  # original_urlを確認するようにしています
  validates :original_url,
    resize_video: true, unless: -> { validation_context == :resize_video }
end

Paperclipによる既存動画の再変換

Paperclipの変換設定を変更した後、既存動画を再変換する必要があります。リスト8のような動画の再変換を実行するRakeタスクを作成して使用しています。

# /lib/tasks/one_time/reprocess_video.rake
namespace :one_time do
  # Example
  # All    : bundle exec rake one_time:reprocess_video
  # ID 4~  : bundle exec rake one_time:reprocess_video[4]
  # ID 4~10: bundle exec rake one_time:reprocess_video[4,10]
  # ID ~10 : bundle exec rake one_time:reprocess_video[,10]
  desc 'Reprocess videos by Paperclip'
  task :reprocess_video, ['from', 'to'] => :environment do |task, args|
    puts 'BEGIN reprocess video'

    target = if args[:from].present? && args[:to].present?
               Video.where(id: args[:from]..args[:to])
             elsif args[:from].present?
               Video.where('id >= ?', args[:from])
             elsif args[:to].present?
               Video.where('id <= ?', args[:to])
             else
               Video
             end

    resize_error_video_ids = []
    convert_width = 640
    convert_height = 640
    target.find_each do |video|
      begin
        ffprobe = Paperclip.run(
          "ffprobe",
          '-i %s -of json -hide_banner -show_entries stream=width,height' % video.video.url
        )
        resolution = JSON.parse(ffprobe)
        original_width = resolution['streams'][0]['width'].to_f
        original_height = resolution['streams'][0]['height'].to_f
        size = if original_width >= original_height
                 (original_height*(convert_width/original_width)).round
               else
                 (original_width*(convert_height/original_height)).round
               end

         unless size % 2 == 0
           resize_error_video_ids << video.id
           next
         end

         video.video.reprocess!
      rescue StandardError, Av::CommandError => e
        puts "Error #{task.name}: video_id => #{video.id}"
        puts e.message + ' : ' + e.backtrace.to_s
        exit 1
      end
    end

    if resize_error_video_ids.present?
      puts "Resize error video ids: #{resize_error_video_ids}"
    end
    puts 'END reprocess video'
  end
end

なお、このRakeタスクは、リサイズ後のサイズが奇数になる動画については、変換処理でエラーが発生するのを避けるため、スキップするようにしています。そのため、再変換が必要であれば、リスト6を参考にして手動で変換してください。

おわりに

FFmpegを使用した動画変換処理で発生した問題について、原因と対応策について紹介しました。

今回の話に出てきた動画変換のエラーは、発生する可能性が十分にあることであり、開発中に動作確認をしていれば気が付くことができることです。当然のことながら、エラーハンドリングを追加するときは、想定内のエラーが捕捉できているのか確認することが重要であると改めて感じました。

2019/12/13 追記
ご指摘していただいた読者様ありがとうございます。

・リスト1のconvert_optionsのvfを”scale=640:-2″に変更することで、リサイズ後のサイズが奇数になってしまう動画も変換できるようになります。
・リスト6では、リサイズ後のサイズが偶数になるように”scale=640:trunc(ih/(iw/640)/2)*2″を使用していますが、”scale=640:-2″にすることで奇数を切り上げて偶数にできます。*4参考文献: https://nico-lab.net/scale_with_ffmpeg/#i-2

   [ + ]

1.MERYのシステム概要: https://mery.dev/616
2.紹介する内容は、技術書典6で頒布されたINSIDE MERY [6.3]と同じ内容です。
3.参考文献: https://qiita.com/genchi-jin/items/90078b6ec751fdacbc9e
4.参考文献: https://nico-lab.net/scale_with_ffmpeg/#i-2
yuki.hoshino

yuki.hoshino

    関連記事
    トップへ戻る