mp4動画をm3u8形式にしてWebページへ
MP4形式の動画をffmpegでHLSとして分割して、hls.jsを使ってWebサイトへ配置してみました。複数の動画のバージョンを用意したり、音声を別にしたりする場合の設定も行っています。
m3u8
m3u8ファイルの語源は、元々 mp3ファイルのプレイリストとして存在していた拡張子、m3u にリストがutf-8エンコーディングという理由から8を付与したとう説があります。
真偽のほどは不明ですが、m3u8ファイルは、HTTP Live Streaming(HLS)で利用されるプレイリストのファイル形式です。
リストに掲載される各アドレスは、通常短い動画ファイルとなっています。このような形にすることによるメリットのひとつに、動画の途中から再生したい場合などに、先頭部をダウンロードせずに済みます。また、細切れにすることができるのでライブ配信にも適しています。
正確にはm3u8 の機能ではでなく HLS の機能なのですが、複数のビットレートのバージョンも設定できるため、柔軟なレスポンスも可能です。
動画を分割
動画を分割して、リスト化するには以前このブログでも紹介したffmpegを利用すると便利です。
sample.mp4 という動画ファイルを分割するにはつぎのようなコマンドを実行します。
-vcodec(video)や、-acodic(audio)でビデオと音声のコーディックの設定ができますが、この値をcopyにすることで元のエンコーディングのまま(再エンコーディングせずに)出力しています。
-fはフォーマット指定しています。これに、hls(Apple HTTP Live Streaming)を指定します。
-hls_list_size オプションはm3u8リストへ含めるファイルの個数です。デフォルトは5となっています。0(全部)を指定していないと、デフォルトの5となります。これは例えばインデックス0~9までの10分割された時にm3u8内の記述はインデックス5~9がついたファイルのみとなります。
バージョンによって異なるかもしれませんが、コマンドを実行すると2秒毎にインデックス付の.tsファイルへ分割出力されます。このとき、m3u8ファイルとして、リストも出力されます。
mp4も、tsも共にコンテナフォーマットです。 ts の方が mp4 よりエラー時の耐性が強いとされており、ストリーミングでは主にtsが利用されるようです。
コンテナフォーマットは、動画と音声の入れ物です。映像も音声も元のまま外側の入れ物を変えるだけなので、変換時に再エンコーディングの必要がありません。
ffproveで mp4ファイルと、tsファイルのひとつを解析してみるとどちらも概ね同じになっていると思います。(メタ情報の保持のされかたや、切り取る時間帯によりビットレート(kb/s)が若干替わっています。)
ffprobe sample.mp4 Stream #0:0[0x100]: Video: h264 (High) ([27][0][0][0] / 0x001B), yuv420p(tv, bt709, progressive), 1280x720, 30 fps, 30 tbr, 90k tbn Stream #0:1[0x101](und): Audio: aac (LC) ([15][0][0][0] / 0x000F), 44100 Hz, stereo, fltp, 98 kb/s ffprobe sample0.ts Stream #0:0[0x1](und): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 89 kb/s (default) Stream #0:1[0x2](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709, progressive), 1280x720, 1818 kb/s, 30 fps, 30 tbr, 600 tbn (default)
ファイルの分割時の再生長さを指定したい場合は、追加のオプションで -hls_timeを指定します。たとえば4秒毎の分割にしたかったら、コマンドを次のように分割します。
いろいろな用語が出てきたので、それらをまとめておきます。
- HLS
HLSはライブ配信のプロトコルのひとつです。このプロトコルでは通常、コンテンツ用にtsファイル、複数のコンテンツをリストするのにm3u8ファイルが用いられます。
- ts(Transport Stream)ファイル
tsファイルは、コンテナフォーマットのひとつです。中には複数の種類のコーディックを含むことができます。
HLSで用いられる場合のその一般的な組み合わせは、動画H.264(AVC)と、音声ACCです。
- m3u8ファイル
m3u8ファイルは、リストを意味するファイルです。HLS以外でも用いられることがあります。
先ほどのffmpegのコマンドでm3u8ファイルに出力されるタグは次の通りです。
- #EXTM3U
プレイリストであることを示すヘッダで、データの最初の行にある必要があります。
- #EXT-X-VERSION:3
HLSのバージョンです。ここでは3を指定しています。
- #EXT-X-TARGETDURATION:4
プレイリスト内の最長のファイルの長さです。ここでは4(秒)になっています。
- #EXT-X-MEDIA-SEQUENCE:0
後続に記述していくファイルのリストのシーケンス番号の初期値です。0が指定されていれば最初にリストしたファイルはシーケンス0、5が指定してあれば、最初にリストしたファイルのシーケンスは5となります。
- #EXTINF:4.000000,
次の行で示すファイルの長さ(秒)を示します。半角,で終わり次の行にファイル名を続けます。
- ファイル名またはURL
ファイル名(パス)かURLです。先のタグとの組み合わせで、再生順にリストします。前述のように、それぞれのファイルはEXT-X-MEDIA-SEQUENCEで設定した初期値から順にシーケンス番号が割り当てられます。
ffmpegでm3u8ファイルを生成すると、ファイル名にも番号が振られますがこの値は単にファイル名であり、リスト内で処理されるシーケンスとは無関係です。
- #EXT-X-ENDLIST
リストの終わりを示します。
ちなみに多くの実装で改行コードはLF,CRLFのどちらにも対応しているようですが、公式にはLFとなっています。
hls.jsを使ってWebへ配置
HLSストリームWebに配置する際のHTMLの記述ですが、ネイティブに再生できるブラウザならvideoタグのsourceにm3u8ファイルを指定すれば再生できるようですが、執筆時点のGoogle Chromeでは対応していません。
Apache バージョン2ライセンスで利用できるhls.jsを使うと、簡単にVideoタグに適用可能です。
CDNでも提供されていますので、先ほど出力したtsファイル群と、m3u8ファイルをWebサーバーのルートに配置して次のようにすれば、ビデオタグに埋め込んだ形で動画を公開できます。
sample.html
...
<head>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</head>
...
<body>
...
<video>
id="video1"
controls
loop
</video>
...
<script>
window.onload = () => {
const video1 = document.getElementById("video1");
if (Hls.isSupported()) {
let hls = new Hls();
// m3u8ファイルのパスを指定
hls.loadSource('sample.m3u8');
hls.attachMedia(video1);
hls.on(Hls.Events.MANIFEST_PARSED, function() {
//Webの仕様で音声ONだと自動再生は効きません
//また後続の処理がエラーで読み飛ばされてしまうので注意が必要です
video1.play();
});
}
};
</script>
...
m3u8ではループ機能を指示するタグは存在しないので、もしループさせたいのならvideoタグの方へつけておきます。
m3u8のカスタマイズ
さきほど紹介した、HLSの機能である複数のバージョンの動画を配信できる機能を使って、高圧縮のH.265のデータを用意しておき、それが使えない場合用にH.264を表示するという設定をしてみます。
H.265はライセンス体系が複雑で、料金を徴収しない個人配信でもライセンス料が必要だといいますので、実際に配信を考えられる方はご注意ください。
(本当はその点がクリアなVP9やAV1で試したかったのですが、VP9はAppleが実装するHLSフォーマットには含まれず、AV1は変換に時間がかかりすぎたのであきらめました)
さきほど、H.264のファイルは作成したので、H.265(HEVC)のファイルを作成します。
オプションの -c:v ビデオコーデックを指定するオプションで、-vcodecと同義です。
次に振り分け用のm3u8ファイル(マスタープレイリスト)を生成します。マスタープレイリストには#EXT-X-ENDLISTタグを含めません。
switch.m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1100000,CODECS="hvc1.1.4.L126.B0"
h265/h265.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1100000,CODECS="avc1.4d0028"
h264/h264.m3u8
EXT-X-STREAM-INFタグは、マスタープレイリストの中で使用されるタグで、ユーザーが利用するデータを選択するための機構です。
HLSでは動画と音声の組み合わせのひとつのことをバリアントストリームと呼びます。
タグの記述で、ユーザーにどのバリアントストリームを選択したらいいかの情報を提供します。
どれが選択されるかはクライアント側にゆだねられられています。先にH.265を定義しておくことでH.265が利用可能な場合はそれを選択してもらいやすくしています。
(筆者の環境では動画は再生できずに、音声だけの状態でH.265を選択してしまうようでした)
ここで定義できる主な属性は次の通りです。
- BANDWIDTH
必須の属性で、動画を再生するための必要な帯域ををビット/秒単位で示します。BANDWIDTHの求め方は、「 動画のサイズ(ビット) / 再生時間(秒) +予備 」とすればいいと思います。
- CODECS
ストリームで利用されるコーデックを示す文字列です。ビデオとオーディオの両方存在する場合は,で区切ります。
たとえば、H.265なら 「 hvc1 」、H.264なら 「 avc1 」です。HE-AACオーディオは「 mp4a.40.5 」、MP3オーディオは「 mp4a.40.34 」となります。ピリオド以降はプロパティで、ファイルの種類によって利用可能な値が変わります。これらは、HLSの仕様を紹介しているAppleのページに詳しい説明があります。
- RESOLUTION
解像度を指定するものです。スクリーン解像度が小さい端末向けにはそれ用のストリームを選択する為に用います。「 RESOLUTION=1280x720 」という具合に設定します。
- FRAME-RATE
ビデオのフレームレートを指定します。
- AUDIO, VIDEO, SUBTITLES, CLOSED-CAPTIONS
=の後に#EXT-X-MEDIAで定義したトラックのIDを指定して、ストリームを指定できます。AUDIOとVIDEOが別に管理されている場合などで用います(後で、AUDIOとVIDEOを別にする際に具体的な使い方を例示します)。
マスタープレイリストファイルができたら、hls.jsのloadSourceメソッドへ渡す値をそれに変更します。
JavaScript側でのm3u8ファイルの切り分け
先ほどのEXT-X-STREAM-INFをつかった方法では、H.265再生環境とH.264再生環境の切り分けはできませんでした。
JavaScriptにも、メディア要素が指定されたMIMEタイプを再生可能か判定するメソッドであるHTMLMediaElement.canPlayType()が存在します。
今度は、JavaScriptのコード内で動画再生の可否を判断して、hls.jsに渡すm3u8ファイルを変えてみます。
canPlayTypeに渡す引数は、MIMEタイプとなります。MDN:「 Codecs in common media types 」に、指定の仕方が詳しく載っています。
例えば、MP3オーディオなら「 audio/mpeg 」、H.264やH.265の動画なら「 video/mp4 」となります。
また、MIMEタイプに続けて、codecs属性を指定できます。こちらは先ほどの、m3u8ファイルのタグで指定したのと同様のコーデックを指定します。コーデックの説明も先のMDNのページに載っています。こちらの方がプロパティについての説明がわかりやすいかもしれません。
sample.html
...
const video1 = document.getElementById("video1");}
// 1 H.265の判定
video1.canPlayType('video/mp4; codecs=hvc1');
// 2 H.264の判定
video1.canPlayType('video/mp4; codecs=avc1');
// 3 プロパティを指定した H.265の判定
video1.canPlayType('video/mp4; codecs=hvc1.1.4.L126.B0');
// 4 プロパティを指定した H.264の判定
video1.canPlayType('video/mp4; codecs=avc1.4d0028');
...
canPlayTypeの戻り値は文字列で、次のようになっています。
- 空白
再生できません
- maybe
MDNのページには、十分な情報が無い為、実際に再生しないとわかりません。
- probably
おそらく再生できます。英単語としてはmaybeよりprobablyの方が確率が高いことになっています。
筆者の環境で、先ほどのコードを実行したところ、
1.空白
2.maybe
3.probably
4.probably
となりました。
筆者の推測ですが、m3u8で分岐させた場合の判定は、このメソッドを使ってprobablyが戻った際に再生可能と判断するのだろうと思います。
先ほどはうまく分岐ができませんでしたが、JavaScriptで判定をしコーデックのプロパティを指定しない場合に、空白とmaybeに分かれるのでこの値を使ってm3u8ファイルの読み込みを分岐できました。
ブラウザがH.265の画像を再生できるかという話からは外れますが、m3u8ファイルでリストされている情報を読みだしておいて、hls.jsからバリアントストリームを選択することも可能です。
...
//読み込みなおしを下かを判定するフラグ
let blnInit = false;
hls.on(Hls.Events.MANIFEST_PARSED, function() {
//バリアントストリーム一覧取得
console.log(hls.levels);
if (blnInit==false) {
blnInit=true;
console.log("change source");
//hls.levels各行のプロパティ値などから対象のバリアントストリームを決定します。
let intTarget = 0;
//url形式でしか、m3u8リストを取得できないので、相対パスにします。
hls.loadSource(getRelativePath(hls.levels[intTarget].url));
} else {
//読み込みなおしが行われると、再度メソッドが呼ばれます
console.log("2nd times");
}
});
//相対パス取得用メソッド
function getRelativePath(url) {
let anchor = document.createElement("a");
anchor.href = url;
return anchor.pathname;
}
オーディオトラックと映像トラックの分割
今度はビデオトラックとオーディオトラックを切り離してみます。H.264とH.265は音声トラックはは同じになるため、共通して用いることでわずかですがディスク容量を削減できます。
先に、ffmpegで映像と音声トラックを切り離して、それぞれをHLSに変換します。-vn は映像を無効にするオプション、-an は音声を無効にするオプションです。
# 音声だけ切り抜き ffmpeg -i sample.mp4 -c:a copy -vn audio.m4a # 映像だけ切り抜き ffmpeg -i sample.mp4 -c:v copy -an video.mp4 # 音声を hls化 ffmpeg -i audio.m4a -c:a copy -f hls -hls_time 2 -hls_list_size 0 audio/audio.m3u8 # 映像をH.265で hls化 ffmpeg -i video.mp4 -c:v libx265 -f hls -hls_time 2 -hls_list_size 0 h265/h265.m3u8 # 映像をH.264で hls化 ffmpeg -i video.mp4 -c:v libx264 -f hls -hls_time 2 -hls_list_size 0 h264/h264.m3u8
-hls_timeに2を指定しても作成される間隔が大きくなってしまう場合は、キーフレームが影響しているかもしれません。音声との同期をするためには間隔は揃えなければいけないので、修正する必要があります。
キーフレーム(Iフレーム:intra frame)は、基準となるフレームで単独でデコード(画像化)が可能なフレームです。他のフレームはこのキーフレームからの差分情報により構成される為単独では映像化できません。
キーフレーム以外のフレームには、P(Prideicted)フレームと、B(Bidirectional predicted)フレームがあります。Pフレームは過去のI,Pフレームからの差分、Bフレームは過去と未来(前後)のI,Pフレームからの差分によって構成されます。
情報量の多さは、B<P<Iとなります。
次のようにキーフレームを強制することで、意図した秒数で分割できます。
expr:はフレーム毎の処理を行う機能で、以降を式として扱います。
tとn_forcedは、ffmpegの変数で、tは現在のフレームのタイムスタンプ、n_forcedはキーフレームが強制挿入された回数を意味します。
gteは(a>=b)の時Trueを返す関数です。
まず0秒時に True になり、キーフレームが強制挿入され、n_forced がインクリメントされるので、次に True になるのは2秒後、以降は繰り返しという理屈です。
キーフレームはエンコーダーの先読みアルゴリズムにも影響するので、あまり多くのキーフレームはエンコーダーにとって有害だとffmpeg公式ページでは言っています。
また、キーフレームを指定するより、GOP(Group of Pictures=ひとつのキーフレームから次のキーフレームまでのまとまり)を指定する方が効果的だとも言っています。
GOPの指定は -g オプションで行いますが、こちらは秒でなくてフレーム数での指定になるのでまず動画のFPSを調べます。
ffprobe -i video.mp4 Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709, progressive), 1280x720, 1818 kb/s, 30 fps, 30 tbr, 19200 tbn (default)
1秒に30フレームだということがわかりましたので、GOPの指定を60にすれば2秒毎になります。
内部的に -keyint_min 60 で最小のキーフレーム間隔や、-sc_threshold 0 でシーン変化(SceneChange)検出によるキーフレームの挿入を防ぐことで、指定した間隔より短いフレームでキーフレームが作られるのを防ぎます。
libx264や265では再エンコーディングをするので、-b オプションで元の映像と同じぐらいのビットレートを指定して、画質を調整する必要があるかもしれません。
また、libx264,libx265には、-crf(Constant Rate Factor)オプションが存在し、値を(0:ロスレス~51:低画質)の間で指定することでも画質を調整できます。
このようにして別々に作成したトラックをマスタープレイリストで定義して、クライアントに選択させます。
EXT-X-MEDIAを使ってオーディオのグループを作成します。DEFAULT属性をYESにしたものがデフォルトの設定となります。
EXT-X-STREAM-INFのAUDIO属性を使ってAUDIOグループ(GRROUP-ID)を指定します、AUDIOグループの使い方の例示の為、英語と日本語に分けてありますが、筆者の環境では言語による(LANGUAGE属性による)自動選択機能は働きませんでした。
sample.html
#EXTM3U
# Audio - 英語
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-group",NAME="audio-en",DEFAULT=NO,LANGUAGE="en",AUTOSELECT=YES,URI="audio/audio_en.m3u8"
# Audio - 日本語
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-group",NAME="audio-ja",DEFAULT=YES,LANGUAGE="ja",AUTOSELECT=YES,URI="audio/audio_jp.m3u8"
# Video hvc
#EXT-X-STREAM-INF:BANDWIDTH=800000,CODECS="hvc1.1.4.L126.B0",AUDIO="audio-group"
h265/h265.m3u8
# Video Default
#EXT-X-STREAM-INF:BANDWIDTH=800000,AUDIO="audio-group"
h264/h264.m3u8
オーディオグループ内での変更はhls側(JavaScript)から可能です。
...
hls.on(Hls.Events.MEDIA_ATTACHED, function() {
let audioTracks = hls.audioTracks;
for(let i = 0; i < audioTracks.length; i++) {
console.log(audioTracks[i]);
}
// メタデータで判定して最初の音声トラックを選択
hls.audioTrack = 0;
});...
>映像と音声を切り離した場合、ちなみに先ほど紹介したm3u8リストの差し替えをしてしまうと、音声情報のないm3u8を読み込んだことになってしまい、音声が再生できませんので注意してください。
執筆にあたりChatGPTのコードや解説を参考にしております。人的なファクトチェックはするように心がけていますが、一部鵜呑みの部分もございます。ご容赦ください。