読者です 読者をやめる 読者になる 読者になる

クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

videoタグのはなし

こんにちは。

開発Div. エンジニアのセイです。 クラウドワークスでクラウドソーシングの開発に携わっています。

簡単な自己紹介ですが、中国の上海出身です。4歳の息子がいます。子どもと遊ぶのは大好きですが、コード書くよりは疲れます。。。

最近開発業務上でvideoタグを触ったチャンスが有ったので、色々試したこと調べてことをメモ帳としてまとめて共有したいと思います。

開発環境:

  • ruby: 2.3.0
  • rails: 4.2.6

ブラウザ:

  • Chrome(48.0.2564.116)
  • Firefox(45.0)
  • Safari(8.0.8)

サンプルビデオ:

まず何も考えずにvideoファイルをそのままレスポンスとして返すように実装してみました

index.html.erb

<%= video_tag(['video/Pollution-SD.mp4'], controls: true) %>

video_controller.rb

  def show
    file_path = "file/#{safe_filename}"
    file_size = File.size?(file_path)

    render status: :not_found, text: 'file not found' and return unless file_size.present?

    send_file file_path, type: MIME::Types.type_for(file_path)[0].to_s
  end

テストしてみましたら、Chromeだと再生は問題無いですが、早送りなどの操作はできないようです(SafariとFirefoxはそういう問題はありません) f:id:dimmy82:20160315193207g:plain

なんでだろう、調べてみましたら f:id:dimmy82:20160315193232p:plain Chromeはvideoタグのリソースに対するリクエストは自動的に Rangeヘッダー付き になります。つまり、Chromeは ステータス206のパーシャルレスポンス を求めていることです

httpstatuses.com

(実際、SafariとFirefoxもRangeヘッダー付きでリクエストしますが、200レスポンスでも再生&早送りなどの操作はできます)

パーシャルレスポンス対応

video_controller.rb

  def show
    file_path = "file/#{safe_filename}"
    file_size = File.size?(file_path)

    render status: :not_found, text: 'file not found' and return unless file_size.present?

    if request.headers['Range'].present?
      # ↓↓↓ 追加したコード
      file_range = request.headers['Range'].match /bytes=(?<start>\d+)-(?<end>\d*)/
      file_start = file_range.present? ? file_range[:start].to_i : 0
      file_end = file_range.present? && file_range[:end].present? ? file_range[:end].to_i : file_size - 1

      if file_start < 0 || file_end >= file_size || file_end < file_start
        render status: :bad_request, text: 'bad request' and return
      end
      file_range_length = file_end - file_start + 1

      response.header['Content-Range'] = "bytes #{file_start}-#{file_end}/#{file_size}"
      response.header['Content-Length'] = file_range_length.to_s
      send_data File.binread(file_path, file_range_length, file_start),
                disposition: 'inline',
                type: MIME::Types.type_for(file_path)[0].to_s ,
                status: :partial_content
      # ↑↑↑ 追加したコード
    else
      send_file file_path, type: MIME::Types.type_for(file_path)[0].to_s
    end
  end

これで解決しました。 f:id:dimmy82:20160315193243g:plain

そこで新たな考えが出てきました。せっかくパーシャル対応しているので、大きなビデオファイルだと、複数回のリクエストに分割して、ダウンロードしながら再生すればサーバの負荷も分散できるし待ち時間も短縮できるではないかと考えていました

ストリームにサイズを制限する

  # 該当ビデオファイルは1.2MBで、1リクエストには300KBの制限をかけ
  # リクエスト4、5回分で全部再生できると想定している
  VIDEO_READ_BUFFER_LIMIT = 300 * 1024

  def show
    file_path = "file/#{safe_filename}"
    file_size = File.size?(file_path)

    render status: :not_found, text: 'file not found' and return unless file_size.present?

    if request.headers['Range'].present?
      file_range = request.headers['Range'].match /bytes=(?<start>\d+)-(?<end>\d*)/
      file_start = file_range.present? ? file_range[:start].to_i : 0
      file_end = file_range.present? && file_range[:end].present? ? file_range[:end].to_i : file_size - 1

      if file_start < 0 || file_end >= file_size || file_end < file_start
        render status: :bad_request, text: 'bad request' and return
      end
      file_range_length = file_end - file_start + 1

      # ↓↓↓ 追加したコード
      if file_range_length > VIDEO_READ_BUFFER_LIMIT
        file_range_length = VIDEO_READ_BUFFER_LIMIT
        file_end = file_range_length + file_start - 1
      end
      # ↑↑↑ 追加したコード

      response.header['Content-Range'] = "bytes #{file_start}-#{file_end}/#{file_size}"
      response.header['Content-Length'] = file_range_length.to_s
      send_data File.binread(file_path, file_range_length, file_start),
                disposition: 'inline',
                type: MIME::Types.type_for(file_path)[0].to_s ,
                status: :partial_content
    else
      send_file file_path, type: MIME::Types.type_for(file_path)[0].to_s
    end
  end

想定通り動いてくれた!! f:id:dimmy82:20160315193324g:plain

と、思ったらFirefoxに怒られました f:id:dimmy82:20160315193552g:plain

Firefoxだと、1回目のレスポンスの内容のみ再生してくれて次のリクエストは投げてくれないようです。 (ちなみに、SafariはChromeと同じくダウンロードしながら再生できます)

色々調べてみました。どうやらそれはFirefoxのバグか仕様か分からないが、2010年既に報告があって、まだ解決されてない状態です

570755 – 206 Partial content is ignored and mozilla does not seek for additional video content

このサイトのコメント履歴から見ると、Firefoxの開発者はそもそもその問題は対応すべきかどうかをずっと議論していて結局何もしませんでした

ちょっと残念ですが、その問題の解決策も考えてなくてとりあえず業務仕様として、ビデオファイルのサイズは大きくないためサイズ制限しない方法でリリースしました

Apache Killer

ApacheのRangeヘッダー対応によってDoS攻撃されたことが昔けっこう騒いてたが、2.2.21以後は対応されたため、必ずApacheのバージョンを確認してください。

d.hatena.ne.jp

各ブラウザの再生できるビデオファイル形式のサポート

単純にvideoタグを使うならば、再生できるビデオファイルの形式はかなり制限されているようです。現状はmp4(コーデック:H.264)とWebM(コーデック:VP8)2種類だけだそうです。

developer.mozilla.org

以上

クラウドワークスでは、無限大の挑戦をしたいエンジニアを募集しています。

www.wantedly.com

© 2016 CrowdWorks, Inc., All rights reserved.