Safari の動画再生のハマりどころ
この記事は最終更新から半年以上経過しており、内容が古い可能性があります。
この記事は TSG Advent Calendar 2020 6日目の記事です。 昨日・5日目の記事はつばめ先輩の「RISC-V の Hypervisor 拡張で Hypervisor もどきを書く」でした。すげー。
ここ数ヶ月私は、今まで数年越しで雑に貯めてきた膨大なオタク・メディア・コンテンツの整理に追われています。動画ファイルをエンコードしたり、それを見やすくするようなインフラや Web アプリケーションを作ったり、といった具合です。
そんな中でハマったのが、Safari での動画再生のサポートです。Chrome や Firefox だと普通に再生できる動画が、Safari だと再生できないことがあるのです。macOS ユーザ向けにはまだ「そんなブラウザ使うな」と言えば済みますが、iOS (including iPadOS) でも再生できないとなると、流石に「Safari を使うな」とは言えません。
というわけで、大人しく Safari での動画再生をサポートするにあたって、つまづいたところをまとめておきます。私が調べた限り、以下がよくまとまった記事は他にありませんでした。お役に立てますように。
注意: 以下では HLS (HTTP Live Streaming) による動画配信については触れていません。単なる静的ビデオファイルの再生についての知見です。
TL;DR
- Safari の通信の仕様により、そもそもダウンロードに失敗している
- ダウンロードには成功しているが、未対応の動画フォーマットが使われているので再生に失敗している
という2パターンがあり得ますので、落ち着いて切り分けて考えましょう。前者かどうかは Safari の Developer Tools の Network タブを見ることで (不親切でわかりづらいですが) 判断できます。
HTTP Range Requests に対応する
ご存知の通り、HTTP には Range Requests という仕様があり (HTTP 206 の文言に沿って Partial Content などと言うことも多いですね)、データを小分けにダウンロードすることが可能です。これによって例えば、長い動画を全てダウンロードせずとも、途中の見たい部分だけをダウンロードしたり、途中で通信が失敗してしまっても、失敗した部分からダウンロードを再開したりすることができます。便利ですね。
しかし今回の僕のケースは、用いるのは短い動画のみ。もちろんこの実装は任意です…と言いたいところですが、Safari の場合、対応が必須です。たとえ1秒しかない小さな動画ファイルでも、Range Request に対応していないものは再生されません。いつも変な思想を強制してくる Apple さん…
もっとも、多くのサーバーは多分対応していると思います。今回の僕のケースでは、Node.js でお手製プロキシを書いていたので、自分で対応する羽目になりました。
const result = await axios.get(`${元サーバー上の動画ファイル}.mp4`, { responseType: 'arraybuffer', }); res.writeHead(200, { 'Content-Type': 'video/mp4', }); res.write(result.data); res.end();
…とだけかくと、これは Range Requests を無視して動画ファイルを丸々返します。上述の通り、Safari はこうしたレスポンスを無視してしまいます。 そこで、以下のように Range Requests にひとまず対応しました:
if (req.headers.range) { const buf = Buffer.from(result.data); const total = buf.length; const [, s, e ] = req.headers.range.match(/bytes=(\d+)-(\d+)?/) || [ '', '', '' ]; const start = parseInt(s); const end = parseInt(e) || total - 1; res.writeHead(206, { 'Content-Type': 'video/mp4', 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': end-start+1, }); const part = buf.slice(start, end+1); res.write(part); res.end(); } else { // 略 }
Safari は、まず Range: bytes=0-1
ヘッダを含めて、Range Requests に対応しているかを伺ってきます。ここで合格をもらえて初めて、続きが読み込まれます。具体的には、どうやら HTTP 206 は必須ではなさそうで (でもちゃんと付けましょう)、Content-Range
ヘッダがあることや、Content-Length
が 2 以下であることが判断基準っぽいです。
また {start}-{end}/{total}
という表記において、start
はその index を含み、end
もその index を含みます。そして total
は合計サイズなので、そのサイズの index は含まれません。この辺で植木算を間違えると、Safari は最後のバイトを求めて無限ループすることもあります (1敗)。こわ。不良品?
ちなみに、Range Request をどう求めるかもブラウザによって違います。手元で簡単に実験した限りだと (disclaimer)、
- Safari は、まず動画が200を返すことを確認したら、次いで
bytes=0-1
をリクエストし、ちゃんと部分的データが返っていることを確認して初めて、続きを読みます。続きが500とかでも普通に読みます。 - Chrome は、まず動画が200を返すことを確認したら、次いで
bytes=0-
をリクエストします。返ってくるデータが部分的でなくても、それはそれで読みます。ただし続きが500とかだと読みません。 - Firefox は、動画が200を返すことを確認したら、普通にそれを読みます。もしレスポンスが 206 でかつ
Content-Range
やContent-Length
を含んでいた場合にのみ、続きの部分データを要求します。
という感じでした。面白いですね。
今回の僕のケースは「短い動画なので必ずしも Range Requests に対応する必要はない」という状況の下、最低限の対応を行いましたが、短くない動画を扱う場合は、絶対 Range Requests に対応したほうがいいでしょう。
お気づきの通り、この HTTP 仕様はとても柔軟なので、真面目に実装しようとすると到底、簡単なコードではカバーできません。そういう時は無駄に車輪を再発明せず、素直に既存のまともなサーバーソフトウェアを使いましょう。
…さて、この対応を済ませれば、Safari はひとまず動画のダウンロードには成功します。Safari の Developer Tools の Network タブにはエラーは現れません。それでも、このアイコンが出て再生できないことがあります:
つまり、対応していないフォーマットが使われているということです。
これは Safari 自身の問題というよりかは、Safari が呼び出している、Apple 製の動画再生フレームワーク AVFoundation の問題と思われます。よって、macOS の QuickLook (Space キーを押して中身を再生できるやつ) や、QuickTime Player アプリでも再生できませんし、iOS の Safari はもちろん、多くの iOS アプリからも再生できません。なんてこった。
私の今回の場合、次の対応が必要でした:
YUV 4:4:4, 4:2:2 を使わない
どうやら YUV 色空間の 4:4:4 や 4:2:2 に対応していないというオチらしく、4:2:0 を使うことで解決します。
FFmpeg なら、-profile:v high -pix_fmt yuvj420p
のように、YUV 420 を指定してエンコードすると良いです。(profile を high にしたのは好みの問題で、なんでも良いです。また、yuv / yuvj もどちらも再生可能。)
最初は H.264 の Profile の問題、つまり Hi444PP (High 4:4:4 Predictive Profile) や Hi422P (High 4:2:2 Profile) が非対応という話かと思ったのですが、それ自体は問題ありません。中身の色空間が本質なので、H.264 Profile が Hi444PP でも、中身が YUV420 なら再生可能です。
動画って難しいよな〜〜〜〜!
その他雑記
- interlace の動画は再生できないという情報がありましたが、私が手元で調べた限り問題ありませんでした。
- 実は AVFoundation のくだりはちょっと自信ない。iOS の AVPlayer でもダメなのかな。ダメに1円賭けます。
- 老人だから AVFoundation のこと QuickTime って書きかけた。でもこの対応状況、実質 QuickTime では? (煽り)
明日・7日目の記事はdaiさんによる「Tensorflow.kerasで学習済みモデルのレイヤーを差し替える」です。