【Unity】hls.jsを用いてWebGL環境でHLS動画を再生する

こじゃらこじゃら

WebGL環境でHLS形式の動画を再生する方法を探しているの。

このはこのは

HLS対応ブラウザならVideoPlayerでそのまま再生できるわ。それ以外ではhls.jsなどのライブラリが必要になるわ。

WebGLのビルド環境でHLS(HTTP Live Streaming)形式の動画を再生する方法の解説記事です。

Safariなどの一部のブラウザではHLS形式が標準でサポートされており、Unity標準のVideoPlayerコンポーネントでそのまま再生可能です。

しかし、執筆時点では非対応ブラウザが多数存在し、再生には別途対策が必要です。

参考:“hls” | Can I use… Support tables for HTML5, CSS3, etc

WebGL環境限定となりますが、hls.jsというライブラリを使用することでHLS非対応ブラウザでのHLS動画の再生に対応できます。

hls.jsはJavaScriptライブラリであるため、Unityから使用するためにはjslibプラグインによるネイティブ実装が必須です。

本記事では、jslibプラグインを通じてからhls.jsを用いてあらゆるブラウザでHLS動画を再生する方法を解説します。以下2種類の方法を解説します。

本記事で解説する方法
  • Unity標準のVideoPlayerの内部処理を一部上書きする(モバイル非対応)
  • 再生機能を独自実装する
動作環境
  • Unity 2023.2.20f1

スポンサーリンク

前提条件

以下のようなQuadなメッシュに対してHLS形式動画をレンダリングするものとします。

また、本記事で解説するHLS動画再生は、WebGLビルド環境でのみ機能し、エディタ環境は対象外なのでご注意ください。

HLS動画再生の実現方法

本題に入る前に、HLSおよびその再生方法について軽く触れます。

HLS(HTTP Live Streaming)とは

HLS(HTTP Live Streaming)とは、Apple Inc. によって開発された動画の規格です。長時間の動画をインターネットを介して配信するのに適した形式になっています。

参考:HTTP Live Streaming 2nd Edition

通常の動画ファイルとは異なり、複数のセグメントファイル(拡張子が.ts)と、これらの情報が記されたプレイリストファイル(拡張子が.m3u8)から成ります。

セグメントファイルは時間で区切られた動画を格納する動画ファイルの実体で、H.264でエンコードされます。

複数のセグメントで分割することにより、動画再生時は該当する時刻が属するセグメントをダウンロードして再生すれば良いことになります。

また、セグメント分割することにより、動画を途中から再生したり、シークしたりする操作が比較的スムーズに実現できるといった恩恵も得られます。

インターネット経由でストリーミング再生する場合、これらのファイルはWebサーバーなどに配置し、HTTP経由で配信します。

また、HLSは通信環境などに応じて品質を段階的に可変にするアダプティブビットレートストリーミング(ABR)にも対応しています。

ABRに対応するには、各品質毎のHLS動画ファイル群とそれらを管理する上位のプレイリストファイルとして管理します。

アダプティブビットレートストリーミングのファイル構成例

HLS動画の再生

HLS動画を再生する際は、まずプレイリストとなる.m3u8ファイルをダウンロードしてセグメント情報等を解釈します。

HLS動画のURLを指定する際は、このプレイリストファイルへのパスを指定します。

URLの例

https://[サーバーへのドメイン]/hls-video.m3u8

解釈した後、再生したい時刻に該当するセグメントファイルをダウンロードし、それをデコードして再生します。

hls.jsでHLS動画を再生する方法

Unity標準のVideoPlayerでは、内部的にvideo要素を作成して動画再生を実現しています。

JS_Video_Create: function (url) {
    var str = UTF8ToString(url);
    var video = document.createElement('video');
    video.style.display = 'none';
    video.src = str;
    video.muted = true;
    video.setAttribute("muted", "");
    video.setAttribute("playsinline", "");

    video.crossOrigin = "anonymous";

Safari等のHLS形式対応ブラウザでは、そのままHLS動画のURLをvideo要素に指定すれば良いです。

 video.src = "https://example.nekojara.city/hls-movie.m3u8";

しかし、HLS非対応ブラウザでは再生出来ません。そのため、非対応ブラウザにも対応させるために本記事ではhls.jsを用います。

hls.jsによりHLS形式動画再生に対応させるためには、まずhls.jsの外部JSをロードさせます。実際には以下のようなscriptタグをDOMに追加すれば良いです。

<script src='https://cdn.jsdelivr.net/npm/hls.js@1'></script>

JavaScriptから動的に追加するには、以下のようにdocument.createElementメソッドを使います。

var isHLSInitialized = false;

const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1';
document.head.appendChild(script);

script.onload = function () {
    isHLSInitialized = true;
}

参考:Document: createElement() メソッド – Web API | MDN

作成したvideo要素に対してHLS動画再生できるようにするため、次のような処理でHlsインスタンスを生成して適用します。

if (isHLSInitialized && str.endsWith('.m3u8')) {
    if (Hls.isSupported()) {
        var hls = new Hls();
        hls.loadSource(str);
        hls.attachMedia(video);
    }
}

VideoPlayerのネイティブコードを書き換えて実現する

一つ目の方法は、Unity標準のVideoPlayerの内部挙動を一部書き換えて実現する方法です。

メリットとデメリットは以下の通りです。

メリット・デメリット
  • メリット
    • 実装が比較的手軽
    • VideoPlayerをそのまま扱える
  • デメリット
    • 内部コードを書き換えるハック的な手段であるためリスクが伴う
    • 非同期な初期化が必要
    • モバイル環境では動作しない(Unity 2023)

手軽な分、ハック的な方法でモバイル非対応の問題を抱えるため、可能なら2つ目の方法として紹介する独自実装が安全でしょう。

参考:Video Player コンポーネント – Unity マニュアル

注意

VideoPlayerでのHLS動画再生は、モバイルブラウザ環境では正しく機能しない事を確認しています。

モバイル環境にも完全対応させるためには、本記事で後述する再生機能を独自実装する方法を実践する必要があります。

実装例

以下、VideoPlayerをHLSに対応させるための.jslibスクリプトです。

サンプルスクリプト(クリックで開きます)
VideoPlayerOverride.jslib
const VideoPlayerOverride = {
    $isHLSInitialized: false,

    JS_Video_InitializeHLS__proxy: 'sync',
    JS_Video_InitializeHLS__sig: 'v',
    JS_Video_InitializeHLS__deps: ['$isHLSInitialized'],
    JS_Video_InitializeHLS: function () {
        if (isHLSInitialized) {
            return;
        }

        const script = document.createElement('script');
        script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1';
        document.head.appendChild(script);

        script.onload = function () {
            isHLSInitialized = true;
        }
    },

    JS_Video_IsHlsInitialized__deps: ['$isHLSInitialized'],
    JS_Video_IsHlsInitialized__proxy: 'sync',
    JS_Video_IsHlsInitialized__sig: 'i',
    JS_Video_IsHlsInitialized: function () {
        return isHLSInitialized;
    },

    JS_Video_Create__proxy: 'sync',
    JS_Video_Create__sig: 'ii',
    JS_Video_Create__deps: ['$videoInstances', '$videoInstanceIdCounter', '$jsVideoEnded', '$hasSRGBATextures', '$UTF8ToString', '$isHLSInitialized'],
    JS_Video_Create: function (url) {
        var str = UTF8ToString(url);
        var video = document.createElement('video');
        video.style.display = 'none';
        video.src = str;
        video.muted = true;
        video.setAttribute("muted", "");
        video.setAttribute("playsinline", "");

        video.crossOrigin = "anonymous";

        if (isHLSInitialized && str.endsWith('.m3u8')) {
            if (Hls.isSupported()) {
                var hls = new Hls();
                hls.loadSource(str);
                hls.attachMedia(video);
            }
        }

        videoInstances[++videoInstanceIdCounter] = video;

        if (hasSRGBATextures == null)
            hasSRGBATextures = Module.SystemInfo.browser == "Chrome" || Module.SystemInfo.browser == "Edge";

        return videoInstanceIdCounter;
    },
};
autoAddDeps(VideoPlayerOverride, '$videoInstances');
autoAddDeps(VideoPlayerOverride, '$isHLSInitialized');
mergeInto(LibraryManager.library, VideoPlayerOverride);

上記をVideoPlayerOverride.jslibなどという名前でPluginsフォルダ配下に保存します。

本記事では以下パスに配置するこことしました。

Assets/Plugins/WebGL/VideoPlayerOverride.jslib

また、上記.jslibを機能させるためには、Unity C#側から初期化関数を呼ぶ必要があります。

以下、hls.jsを初期化してからVideoPlayerの動画を再生するスクリプトの実装例です。

サンプルスクリプト(クリックで開きます)
VideoPlayerExample.cs
using System.Collections;
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.Video;

public class VideoPlayerExample : MonoBehaviour
{
    // 動画再生用のVideoPlayer
    [SerializeField] private VideoPlayer _videoPlayer;

    // ビルド対象がWebGLの場合はJavaScript関数を呼び出す
#if UNITY_WEBGL && !UNITY_EDITOR
    [DllImport("__Internal")]
    private static extern void JS_Video_InitializeHLS();

    [DllImport("__Internal")]
    private static extern bool JS_Video_IsHlsInitialized();
    
    [DllImport("__Internal")]
    private static extern bool JS_Video_CanPlayFormat(string format);
#else
    // ビルド対象がWebGL以外の場合は空の関数を定義

    private static void JS_Video_InitializeHLS()
    {
    }

    private static bool JS_Video_IsHlsInitialized() => true;

    private static bool JS_Video_CanPlayFormat(string format) => true;
#endif

    // hls.jsを初期化してから動画を再生する
    private IEnumerator Start()
    {
        // いったんVideoPlayerを無効化
        _videoPlayer.enabled = false;

        if (JS_Video_CanPlayFormat("application/vnd.apple.mpegurl"))
        {
            // ネイティブでHLS動画を再生可能なので、そのまま再生
            _videoPlayer.enabled = true;
            _videoPlayer.Play();
            yield break;
        }

        // hls.jsの初期化
        JS_Video_InitializeHLS();

        // hls.jsの初期化が完了するまで待機
        yield return new WaitUntil(JS_Video_IsHlsInitialized);

        // VideoPlayerを有効化して再生
        _videoPlayer.enabled = true;
        _videoPlayer.Play();
    }
}

上記をVideoPlayerExample.csという名前でUnityプロジェクトに保存しておきます。

本記事では以下パスに配置するものとします。

Assets/Scripts/VideoPlayerExample.cs

シーンのセットアップ

本記事では、Quadなメッシュに対してVideoPlayerの動画をレンダリングするものとします。

参考:Video Player コンポーネント – Unity マニュアル

空のシーンにQuadなメッシュを配置し、適当にスケールを調整します。

適当なゲームオブジェクトにVideo Playerコンポーネントを追加します。例では前述のQuadメッシュに直接追加するものとします。

この時、Video PlayerコンポーネントのSourceは必ずURLにしてください。

注意

WebGLビルドではURL経由での再生しかできません。ビデオクリップの再生はWebGL環境ではサポートされていないためご注意ください。

参考:Video Player コンポーネント – Unity マニュアル

前述のサンプルスクリプトVideoPlayerExample.csをコンポーネントとして追加します。

このままでは、Quadメッシュに何も表示されないため、表示用のマテリアルを作成して適用するものとします。例ではUnlit/Textureなるマテリアルを作成してRendererに適用することとしました。

最後に、Video PlayerコンポーネントのURLに再生対象のHLS動画ファイルのパスを指定して完了です。

また、Rendererのマテリアルのテクスチャに動画をレンダリングする場合、Video Playerコンポーネントの以下項目の通り設定されていることを確認してください。

  • Render Mode – Material Override
  • Renderer – Rendererコンポーネント

実行結果

ここまでの手順を正しく実施すると、以下のようにHLS形式動画がレンダリングされるようになります。

スクリプトの説明

.jslibファイル側では、次のようにhls.jsを初期化するための関数を用意します。関数内では、hls.jsスクリプトをロードする処理を行っています。

const VideoPlayerOverride = {
    $isHLSInitialized: false,

    JS_Video_InitializeHLS__proxy: 'sync',
    JS_Video_InitializeHLS__sig: 'v',
    JS_Video_InitializeHLS__deps: ['$isHLSInitialized'],
    JS_Video_InitializeHLS: function () {
        if (isHLSInitialized) {
            return;
        }

        const script = document.createElement('script');
        script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1';
        document.head.appendChild(script);

        script.onload = function () {
            isHLSInitialized = true;
        }
    },

そして、Unity C#側から初期化終了かどうか判断するため、以下のように初期化状態を返す関数を定義します。

JS_Video_IsHlsInitialized__deps: ['$isHLSInitialized'],
JS_Video_IsHlsInitialized__proxy: 'sync',
JS_Video_IsHlsInitialized__sig: 'i',
JS_Video_IsHlsInitialized: function () {
    return isHLSInitialized;
},

Unity標準のVideoPlayerでは、初期化時に内部的にJS_Video_Create関数が呼ばれるようです。この関数処理を以下のように再定義して上書きします。

JS_Video_Create__proxy: 'sync',
JS_Video_Create__sig: 'ii',
JS_Video_Create__deps: ['$videoInstances', '$videoInstanceIdCounter', '$jsVideoEnded', '$hasSRGBATextures', '$UTF8ToString', '$isHLSInitialized'],
JS_Video_Create: function (url) {

JS_Video_Create関数内では、video要素を作成した後に、次の処理でHLS動画だったらhls.jsを適用する処理が追加されています。

if (isHLSInitialized && str.endsWith('.m3u8')) {
    if (Hls.isSupported()) {
        var hls = new Hls();
        hls.loadSource(str);
        hls.attachMedia(video);
    }
}

.jslibファイルの最後では、依存関係や関数をマージするための処理を行っています。

autoAddDeps(VideoPlayerOverride, '$videoInstances');
autoAddDeps(VideoPlayerOverride, '$isHLSInitialized');
mergeInto(LibraryManager.library, VideoPlayerOverride);

Unity C#側のスクリプトでは、上記jslibネイティブプラグイン側の関数を実行できるように関数宣言します。

    // ビルド対象がWebGLの場合はJavaScript関数を呼び出す
#if UNITY_WEBGL && !UNITY_EDITOR
    [DllImport("__Internal")]
    private static extern void JS_Video_InitializeHLS();

    [DllImport("__Internal")]
    private static extern bool JS_Video_IsHlsInitialized();
    
    [DllImport("__Internal")]
    private static extern bool JS_Video_CanPlayFormat(string format);
#else
    // ビルド対象がWebGL以外の場合は空の関数を定義

    private static void JS_Video_InitializeHLS()
    {
    }

    private static bool JS_Video_IsHlsInitialized() => true;

    private static bool JS_Video_CanPlayFormat(string format) => true;
#endif

JS_Video_CanPlayFormat関数はUnity側で定義されている関数です。本記事では、ブラウザがHLS形式に対応しているかどうかを判断するために使います。

hls.jsによるHLS動画再生を適用するためには、hls.jsが初期化されてからVideoPlayerの動画再生を実行する必要があります。

これを行うために、例では非同期処理でhls.jsの初期化が完了するまではVideoPlayerを無効にし、初期化完了したら有効にして再生するようにしています。

// hls.jsを初期化してから動画を再生する
private IEnumerator Start()
{
    // いったんVideoPlayerを無効化
    _videoPlayer.enabled = false;

    if (JS_Video_CanPlayFormat("application/vnd.apple.mpegurl"))
    {
        // ネイティブでHLS動画を再生可能なので、そのまま再生
        _videoPlayer.enabled = true;
        _videoPlayer.Play();
        yield break;
    }

    // hls.jsの初期化
    JS_Video_InitializeHLS();

    // hls.jsの初期化が完了するまで待機
    yield return new WaitUntil(JS_Video_IsHlsInitialized);

    // VideoPlayerを有効化して再生
    _videoPlayer.enabled = true;
    _videoPlayer.Play();
}

ブラウザ側でHLSに対応しているかどうかは、「application/vnd.apple.mpegurl」なるフォーマットが対応しているかで確認できます。対応している場合は処理をスキップします。

例ではスクリプトの中で単体のVideoPlayerに対して初期化していますが、実際にアプリケーションを開発する際は初期化シーンなどでhls.jsの初期化を実施するのが良いでしょう。

動画再生を独自実装して実現する

当環境では、1つ目のUnity標準のVideoPlayerを使う方法では、モバイル環境で再生出来ない問題を確認しています。

これらの問題は、VideoPlayerに相当する動画再生機能を独自実装することで解決できます。

2つ目の例では、以下記事の実装例に基づいて動画再生機能を独自実装して適用するものとします。

メリット・デメリット
  • メリット
    • モバイル環境でも問題なく再生できる
  • デメリット
    • 独自再生機能を実装することにより、実装コストが高くなる

実装例

以下、VideoPlayerに相当する動画再生機能を独自実装してHLS形式動画にも対応した例です。スクリプトが複数あるのでご注意ください。

サンプルスクリプト(クリックで開きます)

.jslibファイル

WebGLVideoControl.jslib
var WebGLVideoControl = {
    $webGLVideoControlInstances: {},
    $webGLVideoControlIdCounter: 0,

    $webGLVideoControlHlsJsLoading: false,
    $webGLVideoControlHlsJsLoaded: false,
    $webGLVideoControlPendingHlsInstances: [],

    $WebGLVideoControl_InitializeHlsJs: function () {
        if (webGLVideoControlHlsJsLoading || webGLVideoControlHlsJsLoaded) {
            return;
        }

        const video = document.createElement('video');
        if (video.canPlayType('application/vnd.apple.mpegurl') === '') {
            const script = document.createElement('script');
            script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1';
            document.head.appendChild(script);

            script.onload = function () {
                webGLVideoControlHlsJsLoaded = true;
                webGLVideoControlHlsJsLoading = false;

                for (let i = 0; i < webGLVideoControlPendingHlsInstances.length; i++) {
                    const video = webGLVideoControlPendingHlsInstances[i];
                    const hls = new Hls();
                    hls.loadSource(video.src);
                    hls.attachMedia(video);
                }

                webGLVideoControlPendingHlsInstances = [];
            }
        }

        webGLVideoControlHlsJsLoading = true;
    },

    WebGLVideoControl_Create: function (url) {
        url = UTF8ToString(url);
        const video = document.createElement('video');
        video.style.display = 'none';
        video.src = url;

        if (url.endsWith('.m3u8')) {
            if (webGLVideoControlHlsJsLoaded) {
                if (Hls.isSupported()) {
                    const hls = new Hls();
                    hls.loadSource(url);
                    hls.attachMedia(video);
                }
            } else {
                webGLVideoControlPendingHlsInstances.push(video);
                WebGLVideoControl_InitializeHlsJs();
            }
        }

        video.muted = true;
        video.setAttribute("muted", "");
        video.setAttribute("playsinline", "");
        video.crossOrigin = "anonymous";

        webGLVideoControlInstances[++webGLVideoControlIdCounter] = video;

        return webGLVideoControlIdCounter;
    },

    WebGLVideoControl_UpdateToTexture: function (id, tex) {
        const video = webGLVideoControlInstances[id];

        if (video.seeking) return false;
        if (video.currentTime === video.lastUpdateTextureTime) return false;

        video.lastUpdateTextureTime = video.currentTime;
        GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, true);
        GLctx.bindTexture(GLctx.TEXTURE_2D, GL.textures[tex]);

        GLctx.texImage2D(GLctx.TEXTURE_2D, 0, GLctx.RGBA, GLctx.RGBA, GLctx.UNSIGNED_BYTE, video);
        GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, false);

        return true;
    },

    WebGLVideoControl_Destroy: function (id) {
        const video = webGLVideoControlInstances[id];
        video.src = '';
        delete webGLVideoControlInstances[id];
    },

    WebGLVideoControl_IsReady: function (id) {
        const video = webGLVideoControlInstances[id];
        const targetReadyState = /(iPhone|iPad)/i.test(navigator.userAgent)
            ? video.HAVE_METADATA
            : video.HAVE_ENOUGH_DATA;
        return video.readyState >= targetReadyState;
    },

    WebGLVideoControl_Play: function (id, muted) {
        const video = webGLVideoControlInstances[id];
        video.muted = muted;

        const promise = video.play();

        if (promise !== undefined) {
            promise.catch(error => {
                if (error.name === 'NotAllowedError') {
                    const retryPlay = () => {
                        video.play().then(() => {
                            document.removeEventListener('mousedown', retryPlay);
                            document.removeEventListener('touchstart', retryPlay);
                        }).catch(error => {
                            console.error(error);
                        });
                    };

                    document.addEventListener('mousedown', retryPlay);
                    document.addEventListener('touchstart', retryPlay);
                }
            });
        }
    },

    WebGLVideoControl_Stop: function (id) {
        const video = webGLVideoControlInstances[id];
        video.pause();
        video.currentTime = 0;
    },

    WebGLVideoControl_Pause: function (id) {
        const video = webGLVideoControlInstances[id];
        video.pause();
    },

    WebGLVideoControl_StepForward: function (id) {
        const video = webGLVideoControlInstances[id];
        video.pause();
        video.currentTime += 1 / 24;
    },

    WebGLVideoControl_GetURL: function (id) {
        const video = webGLVideoControlInstances[id];

        const result = video.src;
        var bufferSize = lengthBytesUTF8(result) + 1;
        var buffer = _malloc(bufferSize);
        stringToUTF8(result, buffer, bufferSize);
        return buffer;
    },

    WebGLVideoControl_SetURL: function (id, url) {
        const video = webGLVideoControlInstances[id];
        video.src = UTF8ToString(url);
    },

    WebGLVideoControl_GetTime: function (id) {
        const video = webGLVideoControlInstances[id];
        return video.currentTime;
    },

    WebGLVideoControl_SetTime: function (id, time) {
        const video = webGLVideoControlInstances[id];

        if (video.readyState >= HTMLMediaElement.HAVE_METADATA)
            video.currentTime = time;
    },

    WebGLVideoControl_GetDuration: function (id) {
        const video = webGLVideoControlInstances[id];
        return video.duration;
    },

    WebGLVideoControl_IsMuted: function (id) {
        const video = webGLVideoControlInstances[id];
        return video.muted;
    },

    WebGLVideoControl_SetMuted: function (id, mute) {
        const video = webGLVideoControlInstances[id];
        video.muted = mute;
    },

    WebGLVideoControl_GetWidth: function (id) {
        const video = webGLVideoControlInstances[id];
        return video.videoWidth;
    },

    WebGLVideoControl_GetHeight: function (id) {
        const video = webGLVideoControlInstances[id];
        return video.videoHeight;
    },
};

autoAddDeps(WebGLVideoControl, '$webGLVideoControlInstances');
autoAddDeps(WebGLVideoControl, '$webGLVideoControlIdCounter');
autoAddDeps(WebGLVideoControl, '$webGLVideoControlHlsJsLoading');
autoAddDeps(WebGLVideoControl, '$webGLVideoControlHlsJsLoaded');
autoAddDeps(WebGLVideoControl, '$webGLVideoControlPendingHlsInstances');
autoAddDeps(WebGLVideoControl, '$WebGLVideoControl_InitializeHlsJs');
mergeInto(LibraryManager.library, WebGLVideoControl);

動画操作用インタフェース

IVideoControl.cs
using System;

public interface IVideoControl : IDisposable
{
    void Play();
    void Stop();
    void Pause();
    void StepForward();
    
    string URL { get; set; }
    double Time { get; set; }
    double Duration { get; }
    bool IsMuted { get; set; }
    
    void Update();
}

WebGLの動画再生操作クラス

WebGLVideoControl.cs
using System;
using System.Runtime.InteropServices;
using UnityEngine;
using Object = UnityEngine.Object;

public class WebGLVideoControl : IVideoControl
{
    [DllImport("__Internal")]
    private static extern int WebGLVideoControl_Create(string url);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_UpdateToTexture(int instanceID, IntPtr texture);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_Destroy(int instanceID);

    [DllImport("__Internal")]
    private static extern bool WebGLVideoControl_IsReady(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_Play(int instanceID, bool muted);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_Stop(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_Pause(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_StepForward(int instanceID);

    [DllImport("__Internal")]
    private static extern string WebGLVideoControl_GetURL(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_SetURL(int instanceID, string url);

    [DllImport("__Internal")]
    private static extern double WebGLVideoControl_GetTime(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_SetTime(int instanceID, double time);

    [DllImport("__Internal")]
    private static extern double WebGLVideoControl_GetDuration(int instanceID);

    [DllImport("__Internal")]
    private static extern bool WebGLVideoControl_IsMuted(int instanceID);

    [DllImport("__Internal")]
    private static extern void WebGLVideoControl_SetMuted(int instanceID, bool muted);

    [DllImport("__Internal")]
    private static extern int WebGLVideoControl_GetWidth(int instanceID);

    [DllImport("__Internal")]
    private static extern int WebGLVideoControl_GetHeight(int instanceID);

    // レンダリング対象のマテリアル
    private Material _targetMaterial;

    // レンダリング対象のRenderTexture
    private RenderTexture _targetRenderTexture;

    // 内部管理用のインスタンスID
    private int _videoInstanceID;

    // 動画のテクスチャ
    private Texture2D _texture;

    // sRGBからLinearに変換するためのマテリアル
    private Material _sRGBToLinearMaterial;

    // ミュート状態
    private bool _isMuted;

    // 現在の動画解像度
    private Vector2Int _videoResolution;

    // 動画再生待機中かどうか
    private bool _isWaitingForReady;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="url">動画のURL</param>
    /// <param name="targetRenderer">レンダリング対象のRenderer</param>
    /// <param name="sRGBToLinearShader">sRGBからLinearに変換するためのシェーダー</param>
    public WebGLVideoControl(string url, Renderer targetRenderer, Shader sRGBToLinearShader)
    {
        // 引数を保持
        _targetMaterial = targetRenderer.material;
        _videoInstanceID = WebGLVideoControl_Create(url);

        // 色空間がLinearの場合、動画のテクスチャをsRGBからLinearに変換する必要がある
        if (sRGBToLinearShader != null &&
            QualitySettings.activeColorSpace == ColorSpace.Linear)
        {
            // sRGBからLinearに変換するためのマテリアルを作成
            _sRGBToLinearMaterial = new Material(sRGBToLinearShader);
        }
    }

    /// <summary>
    /// 後処理
    /// </summary>
    public void Dispose()
    {
        // video要素のインスタンスを破棄
        WebGLVideoControl_Destroy(_videoInstanceID);
        _videoInstanceID = 0;

        // テクスチャを破棄
        if (_texture != null)
        {
            Object.Destroy(_texture);
            _texture = null;
        }

        // マテリアルを破棄
        if (_targetMaterial != null)
        {
            Object.Destroy(_targetMaterial);
            _targetMaterial = null;
        }

        if (_sRGBToLinearMaterial != null)
        {
            Object.Destroy(_sRGBToLinearMaterial);
            _sRGBToLinearMaterial = null;
        }
    }

    /// <summary>
    /// 動画を再生する
    /// </summary>
    public void Play()
    {
        if (!WebGLVideoControl_IsReady(_videoInstanceID))
        {
            _isWaitingForReady = true;
            return;
        }

        WebGLVideoControl_Play(_videoInstanceID, _isMuted);
    }

    /// <summary>
    /// 動画再生を停止する
    /// </summary>
    public void Stop()
    {
        WebGLVideoControl_Stop(_videoInstanceID);
    }

    /// <summary>
    /// 動画再生を一時停止する
    /// </summary>
    public void Pause()
    {
        WebGLVideoControl_Pause(_videoInstanceID);
    }

    /// <summary>
    /// コマ送りする
    /// </summary>
    public void StepForward()
    {
        WebGLVideoControl_StepForward(_videoInstanceID);
    }

    /// <summary>
    /// 動画のURL
    /// </summary>
    public string URL
    {
        get => WebGLVideoControl_GetURL(_videoInstanceID);
        set => WebGLVideoControl_SetURL(_videoInstanceID, value);
    }

    /// <summary>
    /// 現在時刻[s]
    /// </summary>
    public double Time
    {
        get => WebGLVideoControl_GetTime(_videoInstanceID);
        set => WebGLVideoControl_SetTime(_videoInstanceID, value);
    }

    /// <summary>
    /// 動画の長さ[s]
    /// </summary>
    public double Duration => WebGLVideoControl_GetDuration(_videoInstanceID);

    /// <summary>
    /// ミュート状態
    /// </summary>
    public bool IsMuted
    {
        get
        {
            _isMuted = WebGLVideoControl_IsMuted(_videoInstanceID);
            return _isMuted;
        }
        set
        {
            _isMuted = value;
            WebGLVideoControl_SetMuted(_videoInstanceID, value);
        }
    }

    /// <summary>
    /// フレーム更新
    /// </summary>
    public void Update()
    {
        // 再生待機中かつ動画が準備完了したら再生
        if (_isWaitingForReady && WebGLVideoControl_IsReady(_videoInstanceID))
        {
            WebGLVideoControl_Play(_videoInstanceID, _isMuted);
            _isWaitingForReady = false;
        }

        // 動画の解像度を取得
        var resolution = new Vector2Int(
            WebGLVideoControl_GetWidth(_videoInstanceID),
            WebGLVideoControl_GetHeight(_videoInstanceID)
        );

        if (resolution.x <= 0 || resolution.y <= 0)
            return;

        // 動画の解像度が変更されたら、動画のテクスチャを再作成
        if (_videoResolution != resolution)
        {
            _videoResolution = resolution;

            if (_texture != null)
            {
                Object.Destroy(_texture);
                _texture = null;
            }

            _texture = new Texture2D(_videoResolution.x, _videoResolution.y, TextureFormat.RGBA32, false);

            if (_targetRenderTexture != null)
            {
                Object.Destroy(_targetRenderTexture);
                _targetRenderTexture = null;
            }

            _targetRenderTexture =
                new RenderTexture(_videoResolution.x, _videoResolution.y, 0, RenderTextureFormat.ARGB32);

            // テクスチャをレンダリング対象のRendererに設定
            _targetMaterial.mainTexture = _targetRenderTexture;
        }

        WebGLVideoControl_UpdateToTexture(_videoInstanceID, _texture.GetNativeTexturePtr());

        if (_sRGBToLinearMaterial != null)
            Graphics.Blit(_texture, _targetRenderTexture, _sRGBToLinearMaterial);
        else
            Graphics.Blit(_texture, _targetRenderTexture);
    }
}

VideoPlayerによる動画操作クラス(主にエディタやWebGL意外のプラットフォームで使用)

VideoPlayerControl.cs
#if UNITY_EDITOR || !UNITY_WEBGL
using UnityEngine;
using UnityEngine.Video;

public class VideoPlayerControl : IVideoControl
{
    // 動画再生用のVideoPlayer
    private VideoPlayer _videoPlayer;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="videoPlayerObject">VideoPlayerを追加するGameObject</param>
    /// <param name="url">動画のURL</param>
    /// <param name="targetRenderer">動画のレンダリング対象のRenderer</param>
    public VideoPlayerControl(GameObject videoPlayerObject, string url, Renderer targetRenderer)
    {
        // VideoPlayerコンポーネントを作成して初期化
        _videoPlayer = videoPlayerObject.AddComponent<VideoPlayer>();
        _videoPlayer.playOnAwake = false;
        _videoPlayer.url = url;
        _videoPlayer.source = VideoSource.Url;
        _videoPlayer.renderMode = VideoRenderMode.MaterialOverride;
        _videoPlayer.targetMaterialRenderer = targetRenderer;
    }

    /// <summary>
    /// 後処理
    /// </summary>
    public void Dispose()
    {
        if (_videoPlayer == null) return;

        // VideoPlayerコンポーネントを削除
        Object.Destroy(_videoPlayer);
        _videoPlayer = null;
    }

    /// <summary>
    /// 動画を再生する
    /// </summary>
    public void Play()
    {
        _videoPlayer.Play();
    }

    /// <summary>
    /// 動画再生を停止する
    /// </summary>
    public void Stop()
    {
        _videoPlayer.Stop();
    }

    /// <summary>
    /// 動画を一時停止する
    /// </summary>
    public void Pause()
    {
        _videoPlayer.Pause();
    }

    /// <summary>
    /// コマ送りする
    /// </summary>
    public void StepForward()
    {
        if (!_videoPlayer.isPaused)
            _videoPlayer.Pause();

        _videoPlayer.StepForward();
    }

    /// <summary>
    /// 動画のURL
    /// </summary>
    public string URL
    {
        get => _videoPlayer.url;
        set => _videoPlayer.url = value;
    }

    /// <summary>
    /// 現在時刻[s]
    /// </summary>
    public double Time
    {
        get => _videoPlayer.time;
        set => _videoPlayer.time = value;
    }

    /// <summary>
    /// 動画の長さ[s]
    /// </summary>
    public double Duration => _videoPlayer.length;

    /// <summary>
    /// ミュート状態
    /// </summary>
    public bool IsMuted
    {
        get
        {
            // どれか一つでもミュートされていないトラックがあれば、ミュートされていないと判定
            var isMuted = true;
            for (ushort i = 0; i < _videoPlayer.audioTrackCount; i++)
            {
                if (_videoPlayer.GetDirectAudioMute(i)) continue;

                isMuted = false;
                break;
            }

            return isMuted;
        }
        set
        {
            // 全てのオーディオトラックのミュート状態を設定
            for (ushort i = 0; i < _videoPlayer.audioTrackCount; i++)
            {
                _videoPlayer.SetDirectAudioMute(i, value);
            }
        }
    }

    public void Update()
    {
        // 何もしない
    }
}
#endif

動画再生用コンポーネント

CustomVideoPlayer.cs
using UnityEngine;

public class CustomVideoPlayer : MonoBehaviour
{
    // 動画のURL
    [SerializeField] private string _url;

    // ミュート状態
    [SerializeField] private bool _isMuted;

    // Awake時に再生するかどうか
    [SerializeField] private bool _playOnAwake;

    // レンダリング先のRenderer
    [SerializeField] private Renderer _targetRenderer;

    // 色空間をsRGBからLinearに変換するシェーダ
    [SerializeField] private Shader _sRGBToLinearShader;

    // 動画再生用のインスタンス
    private IVideoControl _videoControl;

    /// <summary>
    /// 動画を再生する
    /// </summary>
    public void Play()
    {
        _videoControl.Play();
    }

    /// <summary>
    /// 動画再生を停止する
    /// </summary>
    public void Stop()
    {
        _videoControl.Stop();
    }

    /// <summary>
    /// 動画再生を一時停止する
    /// </summary>
    public void Pause()
    {
        _videoControl.Pause();
    }

    /// <summary>
    /// コマ送りする
    /// </summary>
    public void StepForward()
    {
        _videoControl.StepForward();
    }

    /// <summary>
    /// 動画のURL
    /// </summary>
    public string URL
    {
        get => _videoControl.URL;
        set => _videoControl.URL = value;
    }

    /// <summary>
    /// 現在時刻[s]
    /// </summary>
    public double Time
    {
        get => _videoControl.Time;
        set => _videoControl.Time = value;
    }

    /// <summary>
    /// 動画の長さ[s]
    /// </summary>
    public double Duration => _videoControl.Duration;

    /// <summary>
    /// ミュート状態
    /// </summary>
    public bool IsMuted
    {
        get => _isMuted;
        set
        {
            _isMuted = value;
            _videoControl.IsMuted = value;
        }
    }

    private void Awake()
    {
        // WebGLビルド時はWebGLVideoControlを使用
        // それ以外の環境ではVideoPlayerControlを使用
#if !UNITY_EDITOR && UNITY_WEBGL
        _videoControl = new WebGLVideoControl(_url, _targetRenderer, _sRGBToLinearShader);
#else
        _videoControl = new VideoPlayerControl(gameObject, _url, _targetRenderer);
#endif

        if (_playOnAwake) Play();
    }

    private void OnDestroy()
    {
        // 後処理
        _videoControl.Dispose();
    }

    private void Update()
    {
        // フレームの更新処理
        _videoControl.Update();
    }

#if UNITY_EDITOR
    private void OnValidate()
    {
        if (_videoControl == null) return;
        if (_videoControl.URL == _url) return;

        // URLが変更されていたら、新しいURLを設定する
        _videoControl.URL = _url;
    }
#endif
}

上記を全てUnityプロジェクトに保存します。.jslibファイルはPluginsフォルダ配下に置く必要があることにご注意ください。

例では以下パスに配置するものとします。

Assets/Plugins/WebGL/WebGLVideoControl.jslib
Assets/Scripts/IVideoControl.cs
Assets/Scripts/WebGLVideoControl.cs
Assets/Scripts/VideoPlayerControl.cs
Assets/Scripts/CustomVideoPlayer.cs

また、動画再生機能を独自実装する際は、リニアワークフロー時に色味が正しく出力されない問題が発生します。これは動画から転送されたテクスチャの色空間がガンマ空間であり、それをシェーダー側がリニア空間として処理してしまうことが原因です。

そこで、ガンマからリニアに色空間を変換するシェーダーも必要になります。以下はその実装例です。

シェーダー(クリックで開きます)
sRGBToLinear.shader
Shader "Hidden/sRGBToLinear"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            sampler2D _MainTex;

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 sRGB = tex2D(_MainTex, i.uv);
                const float3 c = sRGB.rgb;
                return fixed4(c * (c * (c * 0.305306011 + 0.682171111) + 0.012522878), sRGB.a);
            }
            ENDCG
        }
    }
}

これもUnityプロジェクトに保存します。例では以下パスに保存するものとします。

Assets/Shaders/sRGBToLinear.shader

シーンのセットアップ

シーン構成は1つ目の例と一緒とします。例では、前述の例でセットアップしたシーンを引き継ぎ、追加したVideo PlayerとVideo Player Exampleコンポーネントを削除するものとします。

そして、Custom Video Playerコンポーネント(本記事のサンプルスクリプト)を追加します。

そして、インスペクターより各種項目を設定してください。

  • Url – 動画ファイルのURL(.m3u8ファイルのパス等)
  • Is Muted – 音声をミュートするかどうか
  • Play On Awake – Awakeイベントで自動再生するかどうか
  • Target Renderer – レンダリング対象のRendererコンポーネント
  • SRGB To Linear Shader – 色空間変換シェーダー(sRGBToLinear.shaderを指定)

実行結果

1つ目の例と同様にHLS形式にも対応した動画再生が行えます。

また、iPhone版ChromeやSafariなど、モバイル環境でも動作するようになるはずです。

スクリプトの説明

大部分の実装に関しては、以下記事で解説しています。動画再生の独自実装方法については以下説明をご覧ください。

.jslibプラグイン側では、hls.jsのロードが開始していない場合のみ、初期化処理を行います。

$WebGLVideoControl_InitializeHlsJs: function () {
    if (webGLVideoControlHlsJsLoading || webGLVideoControlHlsJsLoaded) {
        return;
    }

    const video = document.createElement('video');
    if (video.canPlayType('application/vnd.apple.mpegurl') === '') {
        const script = document.createElement('script');
        script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1';
        document.head.appendChild(script);

        script.onload = function () {
            webGLVideoControlHlsJsLoaded = true;
            webGLVideoControlHlsJsLoading = false;

            for (let i = 0; i < webGLVideoControlPendingHlsInstances.length; i++) {
                const video = webGLVideoControlPendingHlsInstances[i];
                const hls = new Hls();
                hls.loadSource(video.src);
                hls.attachMedia(video);
            }

            webGLVideoControlPendingHlsInstances = [];
        }
    }

    webGLVideoControlHlsJsLoading = true;
},

webGLVideoControlPendingHlsInstances変数に、hls.jsの初期化のために再生を保留されているvideo要素リストを格納するようにしました。

動画再生の初期化では、hls.jsが未初期化なら、保留リストに動画を追加して初期化を遅らせています。その後、前述のWebGLVideoControl_InitializeHlsJs関数を実行するようにしています。

if (url.endsWith('.m3u8')) {
    if (webGLVideoControlHlsJsLoaded) {
        if (Hls.isSupported()) {
            const hls = new Hls();
            hls.loadSource(url);
            hls.attachMedia(video);
        }
    } else {
        webGLVideoControlPendingHlsInstances.push(video);
        WebGLVideoControl_InitializeHlsJs();
    }
}

サーバーにHLS動画をアップロードする時の注意点

ストレージサーバーなどにご自身でHLS動画ファイルをアップロードする際は、サーバー側でMIMEとCORSが適切に設定されている必要があります。

MIMEでは、.m3u8と.ts拡張子のファイルに以下のようなMIME Typeがサーバー側で設定されている必要があります。

ファイル拡張子MIME Type
.M3U8application/x-mpegURL
または
vnd.apple.mpegURL
.tsvideo/MP2T

参考:Deploying HTTP Live Streaming

また、異なるドメインの動画URLを指定する場合、配置先のサーバー側でCORS(オリジン間リソース共有)によるドメイン許可が必要です。動画ファイルはGETメソッドでダウンロードする形になるので、GETメソッドを許可していれば良いことになります。

さいごに

hls.jsによるHLS動画再生への対応では、基本的にJavaScriptによる対応が必要なため、Unityで扱うためには.jslibプラグインを実装する必要があります。

Safari等一部のブラウザではHLSに対応しているため、そのままVideoPlayerによる再生が可能ですが、モバイル環境など正常に機能しない場合もあります。

そのため、モバイル環境も含め、全ての環境に対応させるためには動画再生機能を独自実装する手段を取るのが確実です。

関連記事

参考サイト

スポンサーリンク