いうていけろ

hideo54のブログ

Safari の動画再生のハマりどころ

執筆日: 2020年12月6日
最終更新日: 2020年12月8日

この記事は最終更新日から半年以上が経過しており、情報が古い可能性があります。

この記事は 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 でお手製プロキシを書いていたので、自分で対応する羽目になりました。

1
2
3
4
5
6
7
8
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 にひとまず対応しました:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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-RangeContent-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で学習済みモデルのレイヤーを差し替える」です。