PLAY DEVELOPERS BLOG

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

EXT-X-DISCONTINUITY タグがある HLS の Live コンテンツのビットレート切り替えで苦労した話

こんにちは、SaaS事業部の八坂です。

当社は動画配信のプラットフォームを提供しており、私はクライアント側のプレーヤの開発に携わっています。

数年前の話にはなるのですが、ブラウザ上で動作するhls.js(当時v0.8.9を使用)で、HLSのLiveコンテンツに EXT-X-DISCONTINUITY タグがある場合のビットレートの切り替えの際、ローディング状態が継続して切り替えが完了しないという問題が発生しました。
今回は、その問題をどのように対応したか、またその後日談などをお話ししたいと思います。
なお、序盤は、HLSや EXT-X-DISCONTINUITY についての話になるので、お詳しい方は、読み飛ばして頂いて大丈夫です。

HLSとEXT-X-DISCONTINUITYタグについて

現在、動画配信のコンテンツ形式でよく使われているのが、Apple社が提唱したHTTP Live Streaming形式(以下HLS)です。 このHLSの仕様はRFC8216で標準化されてます。
HLSのファイルは、長い動画ファイルを4~10秒程度毎に分割した動画ファイルの断片(セグメントファイル)とそのセグメントファイルの構成を示すプレイリストファイルで成り立ちます。

HLSの配信案件で時々あるのが、複数ある動画の前や後に特定の同じ動画を流したいという要望です。
イメージしやすい例を挙げますと、映画が複数あり、その全ての映画の再生開始前に同じ予告編を入れたい場合などです。
もちろん、予告編と本編を、1つの動画にしてからエンコードすれば実現は可能です。ただし、映画の数が多ければ多いほど、手間と時間がかかってしまいます。
また、予告編と本編をそのまま別動画として順に再生することも可能ですが、この場合、どうしても動画のつなぎ目で一定時間新しい動画の読み込みが発生してしまい、スムーズな動画の切り替えはできません。

HLSでは、この予告編と本編のような別の動画を個々にエンコードし、1つのプレイリストファイルにまとめることで、1つの動画のように扱うことができます。ただし、その場合、境界に EXT-X-DISCONTINUITY タグを挟む必要があります。これらの仕様は、4.3.2.3.項で以下のように定義されています。

4.3.2.3.  EXT-X-DISCONTINUITY

  The EXT-X-DISCONTINUITY tag indicates a discontinuity between the Media Segment that follows it and the one that preceded it.

  Its format is:

   #EXT-X-DISCONTINUITY

  The EXT-X-DISCONTINUITY tag MUST be present if there is a change in any of the following characteristics:
   o  file format
   o  number, type, and identifiers of tracks
   o  timestamp sequence


  The EXT-X-DISCONTINUITY tag SHOULD be present if there is a change in any of the following characteristics:
   o  encoding parameters
   o  encoding sequence

  See Sections 3, 6.2.1, and 6.3.3 for more information about the EXT-X-DISCONTINUITY tag.

その場合のメディアプレイリストは以下のような形式で表されます。
EXT-X-DISCONTINUITY 部分が予告編と本編のような、異なるメディアセグメントの境界であることを示しています。

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.0,
preroll_0.ts
#EXTINF:6.0,
preroll_1.ts
#EXTINF:6.0,
preroll_2.ts
#EXTINF:6.0,
preroll_3.ts
#EXTINF:6.0,
preroll_4.ts
#EXTINF:6.0,
preroll_5.ts
#EXT-X-DISCONTINUITY
#EXTINF:6.0,
movie_0.ts
#EXTINF:6.0,
movie_1.ts
#EXTINF:6.0,
movie_2.ts
#EXTINF:6.0,
movie_3.ts

※中略

#EXT-X-ENDLIST

LiveプレイリストにおけるEXT-X-DISCONTINUITYタグについて

前項では、異なる動画を結合する際に EXT-X-DISCONTINUITY タグを挟むことで、1つの動画のように扱うことができることをお話ししました。 VODのコンテンツの場合、これだけで(まぁ、hls.jsが内部でよしなに処理をしてくれるため)、通常は問題なく再生されます。

ただし、これがLiveコンテンツの場合、プレイリストは「古いセグメントファイルをプレイリストから削除し、新しいセグメントファイルをリストに追加する」という振る舞いをします。
設定によっては、「古いセグメントファイルはプレイリストに残したまま、新しいセグメントファイルをリストに追加する」という振る舞いをする場合もありますが、今回の件の観点では事実上VODと同じ扱いになり、後述する問題も起きませんので論及しません。

前者の振る舞いをするLiveコンテンツのプレイリストの場合、以下のように徐々に EXT-X-DISCONTINUITY タグの位置が上がっていきます。

まず、初期状態が下記の状態だとします。

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.0,
preroll_0.ts
#EXTINF:6.0,
preroll_1.ts
#EXTINF:6.0,
preroll_2.ts
#EXTINF:6.0,
preroll_3.ts
#EXTINF:6.0,
preroll_4.ts
#EXTINF:6.0,
preroll_5.ts
#EXT-X-DISCONTINUITY
#EXTINF:6.0,
movie_0.ts
#EXTINF:6.0,
movie_1.ts
#EXTINF:6.0,
movie_2.ts
#EXTINF:6.0,
movie_3.ts

上記のプレイリストから5回、更新が行われると、以下のようなプレイリストになります。

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:5
#EXTINF:6.0,
preroll_5.ts
#EXT-X-DISCONTINUITY
#EXTINF:6.0,
movie_0.ts
#EXTINF:6.0,
movie_1.ts
#EXTINF:6.0,
movie_2.ts
#EXTINF:6.0,
movie_3.ts
#EXTINF:6.0,
movie_4.ts
#EXTINF:6.0,
movie_5.ts
#EXTINF:6.0,
movie_6.ts
#EXTINF:6.0,
movie_7.ts
#EXTINF:6.0,
movie_8.ts

そして、次のプレイリスト更新タイミングで、EXT-X-DISCONTINUITY がプレイリストから消えます。

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:6
#EXTINF:6.0,
movie_0.ts
#EXTINF:6.0,
movie_1.ts
#EXTINF:6.0,
movie_2.ts
#EXTINF:6.0,
movie_3.ts
#EXTINF:6.0,
movie_4.ts
#EXTINF:6.0,
movie_5.ts
#EXTINF:6.0,
movie_6.ts
#EXTINF:6.0,
movie_7.ts
#EXTINF:6.0,
movie_8.ts
#EXTINF:6.0,
movie_9.ts

発生した不具合について

それでは、本題に入ります。まずは、当社で発生した不具合についてまとめます。
なお、すぐ上の EXT-X-DISCONTINUITY が消えた直後のプレイリストを見て、「あれ?」となった方は、恐らく、以降の情報はあまり必要ないかと思われます。

【事象】

Liveコンテンツのプレイリストから EXT-X-DISCONTINUITY タグが消えた後にビットレートの切り替えを行った場合、切り替え時にローディングが発生し、切り替えが完了しない。 (以下失敗時)
※Liveコンテンツのプレイリストに EXT-X-DISCONTINUITY タグが残っているタイミングの切り替えであれば、速やかに切り替えが完了する。(以下成功時)
※VODコンテンツはプレイリストから EXT-X-DISCONTINUITY タグが消えるケースがないため、不具合は発生しない。こちらの件も以降、論及しません。

【環境】

Windows、mac上のブラウザ(Chrome、FireFox、IE11)
※mac - Safariでは再現しない(hls.jsを使用せずブラウザのプレーヤを使用していたため)

解析

基本的には、成功時と失敗時の振る舞いの差異に着目して、以下のように追っていきました。

コンテンツサーバとの通信観点

Webインスペクタのネットワークタブにて、ビットレート切り替え直後の動画ファイルのダウンロードの仕方を比較したところ、振る舞いに以下のような差異がありました。

成功時は、切り替え前に最後に取得した動画ファイルに対応する切り替え後の動画ファイル以降のプレイリストの動画ファイルを受信した。
失敗時は、切り替え前に最後に取得した動画ファイルに対応する切り替え後の動画ファイルより2~3個手前の動画ファイルのみを受信し、以降、ローディング中は次の動画ファイルの受信を行わない。

ソースコード観点

「コンテンツサーバとの通信観点」で述べた振る舞いの差異は、以下の _fetchPayloadOrEos 関数内で起きてました。

成功時は、(1)の _loadFragmentOrKey 関数の呼び出しをセグメントファイル数回行い、関数の呼び先でセグメントファイルをダウンロードします。
失敗時は、切り替え直後は(1)の _loadFragmentOrKey 関数を呼び出し、セグメントファイルをダウンロードしますが、2回目以降は(2)の _ensureFragmentAtLivePoint 関数の戻り値がnullのため関数から抜けてしまう。結果、セグメントファイルを1つしかダウンロードしない振る舞いになっていました。

stream-controller.js - _fetchPayloadOrEos()

  _fetchPayloadOrEos(pos, bufferInfo, levelDetails) {
    const fragPrevious = this.fragPrevious,
          level = this.level,
          fragments = levelDetails.fragments,
          fragLen = fragments.length;

    // empty playlist
    if (fragLen === 0) {
      return;
    }

    // find fragment index, contiguous with end of buffer position
    let start = fragments[0].start,
        end = fragments[fragLen-1].start + fragments[fragLen-1].duration,
        bufferEnd = bufferInfo.end,
        frag;

    if (levelDetails.initSegment && !levelDetails.initSegment.data) {
      frag = levelDetails.initSegment;
    } else {
      // in case of live playlist we need to ensure that requested position is not located before playlist start
      if (levelDetails.live) {
        let initialLiveManifestSize = this.config.initialLiveManifestSize;
        if(fragLen < initialLiveManifestSize){
          logger.warn(`Can not start playback of a level, reason: not enough fragments ${fragLen} < ${initialLiveManifestSize}`);
          return;
        }

        // ↓(2) 失敗時は2回目以降この下の関数の戻り値がnullとなるため、この関数を抜けてしまう
        frag = this._ensureFragmentAtLivePoint(levelDetails, bufferEnd, start, end, fragPrevious, fragments, fragLen);
        // if it explicitely returns null don't load any fragment and exit function now
        if (frag === null) {
          return;
        }

      } else {
        // VoD playlist: if bufferEnd before start of playlist, load first fragment
        if (bufferEnd < start) {
          frag = fragments[0];
        }
      }
    }
    if (!frag) {
      frag = this._findFragment(start, fragPrevious, fragLen, fragments, bufferEnd, end, levelDetails);
    }
    if(frag) {
      // ↓(1) 成功時は複数回、失敗時は最初の1回のみこの関数が呼ばれ、セグメントファイルをダウンロードする
      this._loadFragmentOrKey(frag, level, levelDetails, pos, bufferEnd);
    }
    return;
  }

上述の(2)の _ensureFragmentAtLivePoint 関数内でnullが返される理由は、下に引用したこの関数の(3)の部分で、PTSKnown が1回目はfalseなのに対し、2回目以降はtrueになっているためでした。

stream-controller.js - _ensureFragmentAtLivePoint()

  _ensureFragmentAtLivePoint(levelDetails, bufferEnd, start, end, fragPrevious, fragments, fragLen) {
    const config = this.hls.config, media = this.media;

    let frag;

    // check if requested position is within seekable boundaries :
    //logger.log(`start/pos/bufEnd/seeking:${start.toFixed(3)}/${pos.toFixed(3)}/${bufferEnd.toFixed(3)}/${this.media.seeking}`);
    let maxLatency = config.liveMaxLatencyDuration !== undefined ? config.liveMaxLatencyDuration : config.liveMaxLatencyDurationCount*levelDetails.targetduration;

    if (bufferEnd < Math.max(start-config.maxFragLookUpTolerance, end - maxLatency)) {
        let liveSyncPosition = this.liveSyncPosition = this.computeLivePosition(start, levelDetails);
        logger.log(`buffer end: ${bufferEnd.toFixed(3)} is located too far from the end of live sliding playlist, reset currentTime to : ${liveSyncPosition.toFixed(3)}`);
        bufferEnd = liveSyncPosition;
        if (media && media.readyState && media.duration > liveSyncPosition) {
          media.currentTime = liveSyncPosition;
        }
        this.nextLoadPosition = liveSyncPosition;
    }

    // if end of buffer greater than live edge, don't load any fragment
    // this could happen if live playlist intermittently slides in the past.
    // level 1 loaded [182580161,182580167]
    // level 1 loaded [182580162,182580169]
    // Loading 182580168 of [182580162 ,182580169],level 1 ..
    // Loading 182580169 of [182580162 ,182580169],level 1 ..
    // level 1 loaded [182580162,182580168] <============= here we should have bufferEnd > end. in that case break to avoid reloading 182580168
    // level 1 loaded [182580164,182580171]
    //
    // don't return null in case media not loaded yet (readystate === 0)

    // ↓(3) levelDetails.PTSKnownの値が初回の呼び出し(false)と2回目以降の呼び出し(true)で差異があり、2回目以降はif文の条件を満たしてしまいnullを返却する
    if (levelDetails.PTSKnown && bufferEnd > end && media && media.readyState) {
      return null;
    }

※中略

    return frag;
  }

この PTSKnown は、ビットレート切り替え時にfalseに設定されます。
そして、成功時は、adjustPts 関数内でtrueに設定しています。
一方、失敗時は adjustPts 関数が呼ばれず、updateFragPTSDTS 関数でtrueに設定しています。

この adjustPts 関数を呼び出すか否かは結果的に下の shouldAlignOnDiscontinuities 関数内の(4)の判定で決定づけられます。
成功時は、プレイリストをパースした際、EXT-X-DISCONTINUITY タグを検出し、endCC が1となります。(正確には、検出した EXT-X-DISCONTINUITY タグの数分インクリメントされます)
一方、失敗時のパース後の endCC は、EXT-X-DISCONTINUITY タグを検出しないため、0となります。
いずれのケースも startCC は0のため、
成功時はif文の条件を満たし、結果的にこの関数でtrueを返すのに対し、
失敗時はfalseを返すこととなります。

discontinuities.js - shouldAlignOnDiscontinuities()

export function shouldAlignOnDiscontinuities(lastFrag, lastLevel, details) {
  let shouldAlign = false;
  if (lastLevel && lastLevel.details && details) {
    // ↓(4) 
    if (details.endCC > details.startCC || (lastFrag && lastFrag.cc < details.startCC)) {
      shouldAlign = true;
    }
  }
  return shouldAlign;
}

その結果、
成功時は、プレイリスト中の不連続(EXT-X-DISCONTINUITY タグ)が検出されているため、adjustPts 関数が呼ばれ、PTSの調整が行われます。
失敗時は、adjustPts が呼ばれないため、PTSの調整が行われず、いつまでもPTSが期待の値に到達せずにローディングが継続することが判明しました。

対策

これらの解析結果から、以下のような対策を行いました。

  • ビットレート切り替え時、切り替え前のセグメントの endCC (正確には cc)の値をチェックする。endCC が0より大きい値の場合、EXT-X-DISCONTINUITY タグが含まれていたと判断し、adjustPts 関数を呼び出してPTSの調整を行うようにする。

なお、具体的なソースの改変は、以下の通りです。

(1) メディアプレイリスト読み込み時、その要因がビットレートの切り替えか否かをチェックする。ビットレート切り替えの場合、切り替え前のセグメントに不連続なセグメント(EXT-X-DISCONTINUITYタグ)が含まれていた場合、findDiscontinuities にtrueを設定する。
(2) findDiscontinuities の値を引数に追加して、`alignDiscontinuities 関数を呼び出す。

stream-controller.js - onLevelLoaded()

  onLevelLoaded(data) {
    const newDetails = data.details;
    const newLevelId = data.level;
    const lastLevel = this.levels[this.levelLastLoaded];
    const curLevel = this.levels[newLevelId];
    const duration = newDetails.totalduration;
    let sliding = 0;

    logger.log(`level ${newLevelId} loaded [${newDetails.startSN},${newDetails.endSN}],duration:${duration}`);


    // ↓(1)
    var findDiscontinuities = false;
    if (newDetails.live && this.levelLastLoaded !== undefined && newLevelId !== this.levelLastLoaded && newDetails.fragments.length) {
      // Bitrate切り替え検出時
      findDiscontinuities = this.findDiscontinuitiesBeforeSwitching(lastLevel);
    }
    // ↑(1)

    if (newDetails.live) {
      var curDetails = curLevel.details;
      if (curDetails && newDetails.fragments.length > 0) {
        // we already have details for that level, merge them
        LevelHelper.mergeDetails(curDetails,newDetails);
        sliding = newDetails.fragments[0].start;
        this.liveSyncPosition = this.computeLivePosition(sliding, curDetails);
        if (newDetails.PTSKnown && !isNaN(sliding)) {
          logger.log(`live playlist sliding:${sliding.toFixed(3)}`);
        } else {
          logger.log('live playlist - outdated PTS, unknown sliding');
          // ↓(2)
          alignDiscontinuities(this.fragPrevious, lastLevel, newDetails, findDiscontinuities);
        }
      } else {
        logger.log('live playlist - first load, unknown sliding');
        newDetails.PTSKnown = false;
        // ↓(2)
        alignDiscontinuities(this.fragPrevious, lastLevel, newDetails, findDiscontinuities);
      }
    } else {
      newDetails.PTSKnown = false;
    }

    ※後略

(3) (1)で呼び出した、切り替え前のプレイリストに EXT-X-DISCONTINUITY タグが含まれていたかチェックする、findDiscontinuitiesBeforeSwitching 関数を追加。

stream-controller.js - findDiscontinuitiesBeforeSwitching()

  // ↓(3) 関数追加
  findDiscontinuitiesBeforeSwitching(lastLevel) {
    let result =false;

    let lastDetails = lastLevel.details;
    let length = lastDetails.fragments.length;
    let cc = lastDetails.fragments[length - 1].cc;
    if (cc > 0) {
      // lastDetailsのccが1以上(EXT-X-DISCONTINUITYタグが検出されている)
      result = true;
    }
    
    return result;
  }

(4) (2)で呼び出した、alignDiscontinuities 関数に findDiscontinuities 引数を追加する。
(5) findDiscontinuities がtrueの場合、shouldAlignOnDiscontinuities の結果に関わらず、adjustPts 関数を呼び出すように変更。

discontinuities.js - alignDiscontinuities()

// ↓(4) 引数findDiscontinuitiesを追加
export function alignDiscontinuities(lastFrag, lastLevel, details, findDiscontinuities) {
  // ↓(5)
  if (findDiscontinuities || shouldAlignOnDiscontinuities(lastFrag, lastLevel, details)) {
    const referenceFrag = findDiscontinuousReferenceFrag(lastLevel.details, details);
    if (referenceFrag) {
      logger.log('Adjusting PTS using last level due to CC increase within current level');
      adjustPts(referenceFrag.start, details);
    }
  }

    ※後略

改修結果

コンテンツのビットレート数が2個の場合、問題なくビットレートの切り替えが行えるようになりました。
ただし、コンテンツのビットレート数を3個にした場合、正しく adjustPts を呼び出さない場合があることが分かりました。

後日談というかオチというか…

これまでの対応でビットレート数が2個の場合、問題なくビットレートの切り替えが可能となりました。一旦、問題となっていた案件はこの対策で凌ぎました。
しかしながら、3個以上では問題の解消はできておらず、引き続き問題が再現することが分かりました。
今回の問題は、Safariでは問題なく動作していたこともあり、hls.jsの不具合であると考え、issueを上げてみました。

そして、以下の回答を受領しました。

You are missing the tag: EXT-X-DISCONTINUITY-SEQUENCE

EXT-X-MEDIA-SEQUENCE should increase.

150 -> 80 
Not a valid stream

いや、EXT-X-DISCONTINUITY タグは、そりゃ消えてるからなくて当然…ん? EXT-X-DISCONTINUITY-"SEQUENCE"??慌てて、HLSの仕様書を調べてみますと、確かに4.3.3.3.項EXT-X-DISCONTINUITY-SEQUENCE という項がありました! 以下にその規定を抜粋します。

4.3.3.3.  EXT-X-DISCONTINUITY-SEQUENCE

   The EXT-X-DISCONTINUITY-SEQUENCE tag allows synchronization between
   different Renditions of the same Variant Stream or different Variant
   Streams that have EXT-X-DISCONTINUITY tags in their Media Playlists.

   Its format is:

   #EXT-X-DISCONTINUITY-SEQUENCE:<number>

   where number is a decimal-integer.

※中略

   See Sections 6.2.1 and 6.2.2 for more information about setting the
   value of the EXT-X-DISCONTINUITY-SEQUENCE tag.

そして、参照先の6.2.2.項には、そのものズバリの記述がありました。以下は、6.2.2.項の抜粋です。

If the server wishes to remove segments from a Media Playlist containing an EXT-X-DISCONTINUITY tag,  
the Media Playlist MUST contain an EXT-X-DISCONTINUITY-SEQUENCE tag.  
Without the EXT-X-DISCONTINUITY-SEQUENCE tag,  
it can be impossible for a client to locate corresponding segments between Renditions.

「サーバーが EXT-X-DISCONTINUITY タグを含むメディア プレイリストからセグメントを削除したい場合、メディア プレイリストには EXT-X-DISCONTINUITY-SEQUENCE タグを含める必要があります。」と。

まとめ

結局、上記の例で示した、「そして、プレイリストの次の更新タイミングで、EXT-X-DISCONTINUITY がプレイリストから消え」た時のプレイリストに誤りがあり、正しくは、以下のようになっている必要がありました。この時に使用していたLiveエンコーダーの不具合で「EXT-X-DISCONTINUITY タグが消えても、EXT-X-DISCONTINUITY-SEQUENCE タグが付与(インクリメント)されない」動作となっていたことが今回の件の根本原因でした。

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:6
#EXT-X-DISCONTINUITY-SEQUENCE:1  ←このタグが必要だった
#EXTINF:6.0,
movie_0.ts
#EXTINF:6.0,
movie_1.ts
#EXTINF:6.0,
movie_2.ts
#EXTINF:6.0,
movie_3.ts
#EXTINF:6.0,
movie_4.ts
#EXTINF:6.0,
movie_5.ts
#EXTINF:6.0,
movie_6.ts
#EXTINF:6.0,
movie_7.ts
#EXTINF:6.0,
movie_8.ts
#EXTINF:6.0,
movie_9.ts

そして改めて、プレイリストのパース処理を確認すると、

playlist-loader.js - parseLevelPlaylist()

        case 'DISCONTINUITY-SEQ':
          cc = parseInt(value1);
          break;

というコードがあり、X-DISCONTINUITY-SEQUENCE がある場合、その値を cc として設定する処理となっていました。 きちんと EXT-X-DISCONTINUITY-SEQUENCE タグが設定されていれば、問題なくビットレートの切り替えが出来ていたものと思われます。

今回のようにクライアントの環境毎に振る舞いに差異がある場合、どうしてもそれだけで期待動作しない環境の不具合であると結論づけてしまいがちになってしまいますが、ハマった時にこそ、もう少し視野を広げて色々なケースを想定すべきだなと。
そして、言い古されていることですが、仕様書は英語でも忌避せずにちゃんと読みましょう…という教訓を得たできごとでした。