媒體來源擴充功能

François Beaufort
François Beaufort
Joe Medley
Joe Medley

Media Source Extensions (MSE) 是 JavaScript API,可讓您建立從音訊或影片片段播放的串流。雖然本文未提及,但如果要在網站中嵌入影片,以便執行以下操作,就必須瞭解 MSE:

  • 智慧串流,也就是根據裝置功能和網路情況調整
  • 自動調整剪接,例如插入廣告
  • 時間偏移
  • 控制效能和下載大小
基本 MSE 資料流
圖 1:基本 MSE 資料流程

您可以將 MSE 視為鏈條。如圖所示,下載的檔案和媒體元素之間有幾個層級。

  • 用於播放媒體的 <audio><video> 元素。
  • 含有 SourceBufferMediaSource 例項,用於饋送媒體元素。
  • fetch() 或 XHR 呼叫,用於擷取 Response 物件中的媒體資料。
  • 呼叫 Response.arrayBuffer() 以餵食 MediaSource.SourceBuffer

實際上,鏈結會如下所示:

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
  console.log('The Media Source Extensions API is not supported.');
}

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp09.00.10.08"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  fetch(videoUrl)
    .then(function (response) {
      return response.arrayBuffer();
    })
    .then(function (arrayBuffer) {
      sourceBuffer.addEventListener('updateend', function (e) {
        if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
          mediaSource.endOfStream();
        }
      });
      sourceBuffer.appendBuffer(arrayBuffer);
    });
}

如果您可以根據上述說明解決問題,請隨時停止閱讀。如需更詳細的說明,請繼續閱讀。我將透過建立基本 MSE 範例,逐步說明這個鏈結。每個建構步驟都會在先前的步驟中新增程式碼。

清晰度注意事項

這篇文章會說明在網頁上播放媒體的所有相關資訊嗎?否,這只是為了協助您瞭解其他地方可能會出現的更複雜程式碼。為了方便說明,本文件會簡化並排除許多內容。我們認為可以採用這個做法,因為我們也建議使用 Google 的 Shaka Player 等程式庫。我會在整個過程中指出我刻意簡化的部分。

不涵蓋的幾項事項

以下是幾項我不會涵蓋的內容 (順序不代表重要程度)。

  • 播放控制項。我們使用 HTML5 <audio><video> 元素,就能免費取得這些元素。
  • 錯誤處理。

適用於正式環境

以下是建議在實際工作環境中使用 MSE 相關 API 時的做法:

  • 呼叫這些 API 前,請先處理任何錯誤事件或 API 例外狀況,並檢查 HTMLMediaElement.readyStateMediaSource.readyState。這些值可能會在相關事件傳送前變更。
  • 請先檢查 SourceBuffer.updating 布林值,再更新 SourceBuffermodetimestampOffsetappendWindowStartappendWindowEnd,或在 SourceBuffer 上呼叫 appendBuffer()remove(),以確保先前的 appendBuffer()remove() 呼叫仍在進行中。
  • 對於新增至 MediaSource 的所有 SourceBuffer 例項,請在呼叫 MediaSource.endOfStream() 或更新 MediaSource.duration 之前,確認其 updating 值皆為 false。
  • 如果 MediaSource.readyState 值為 endedappendBuffer()remove() 等呼叫,或設定 SourceBuffer.modeSourceBuffer.timestampOffset,都會導致這個值轉換為 open。也就是說,您應該準備好處理多個 sourceopen 事件。
  • 處理 HTMLMediaElement error 事件時,MediaError.message 的內容可能有助於判斷失敗的根本原因,尤其是在測試環境中難以重現的錯誤。

將 MediaSource 例項附加至媒體元素

就像現在網頁開發中的許多事物一樣,您可以先從功能偵測開始。接著,取得媒體元素 (<audio><video> 元素)。最後,建立 MediaSource 的例項。系統會將其轉換為網址,並傳遞至媒體元素的來源屬性。

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  // Is the MediaSource instance ready?
} else {
  console.log('The Media Source Extensions API is not supported.');
}
來源屬性為 Blob
圖 1:來源屬性為 blob

MediaSource 物件可傳遞至 src 屬性,這可能看起來有點奇怪。這些通常是字串,但也可能是 blob。如果你檢查含有嵌入媒體的網頁,並查看其媒體元素,就會明白我的意思。

MediaSource 例項是否已就緒?

URL.createObjectURL() 本身是同步的,但會以非同步方式處理附件。這會導致您在使用 MediaSource 執行個體時稍微延遲。幸運的是,我們有方法可以測試這項問題。最簡單的方法是使用名為 readyStateMediaSource 屬性。readyState 屬性會說明 MediaSource 例項與媒體元素之間的關係。可包含下列其中一個值:

  • closedMediaSource 例項未附加至媒體元素。
  • openMediaSource 例項已附加至媒體元素,並已準備好接收資料或正在接收資料。
  • endedMediaSource 例項已附加至媒體元素,且所有資料都已傳遞至該元素。

直接查詢這些選項可能會對效能產生負面影響。幸好,MediaSource 也會在 readyState 變更時觸發事件,特別是 sourceopensourceclosedsourceended。在這個範例中,我會使用 sourceopen 事件來告知何時擷取及緩衝影片。

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  <strong>mediaSource.addEventListener('sourceopen', sourceOpen);</strong>
} else {
  console.log("The Media Source Extensions API is not supported.")
}

<strong>function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  // Create a SourceBuffer and get the media file.
}</strong>

請注意,我也會呼叫 revokeObjectURL()。我知道這似乎太早了,但在媒體元素的 src 屬性連結至 MediaSource 例項後,我隨時可以執行這項操作。呼叫這個方法不會銷毀任何物件。它「確實」允許平台在適當時間處理垃圾收集,這也是我立即呼叫的原因。

建立 SourceBuffer

接下來,我們要建立 SourceBuffer,這是實際在媒體來源和媒體元素之間傳送資料的物件。SourceBuffer 必須針對您要載入的媒體檔案類型進行設定。

實際上,您可以呼叫 addSourceBuffer() 並傳入適當的值。請注意,在下方範例中,MIME 類型字串包含 MIME 類型和兩個編碼器。這是影片檔案的 mime 字串,但會為檔案的影片和音訊部分使用不同的編解碼器。

MSE 規格第 1 版允許使用者代理程式在要求 MIME 類型和編解碼時有所不同。有些使用者代理程式不要求,但允許使用 MIME 類型。部分使用者代理程式 (例如 Chrome) 需要編碼器,以便處理不自行描述編碼器的 mime 類型。與其嘗試將所有內容分類,不如直接加入這兩者。

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
  console.log('The Media Source Extensions API is not supported.');
}

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  <strong>
    var mime = 'video/webm; codecs="opus, vp09.00.10.08"'; // e.target refers to
    the mediaSource instance. // Store it in a variable so it can be used in a
    closure. var mediaSource = e.target; var sourceBuffer =
    mediaSource.addSourceBuffer(mime); // Fetch and process the video.
  </strong>;
}

取得媒體檔案

只要在網路上搜尋 MSE 範例,就會發現許多範例會使用 XHR 擷取媒體檔案。為了讓內容更前衛,我將使用 Fetch API 和其傳回的 Promise。如果您嘗試在 Safari 中執行此操作,必須使用 fetch() polyfill,否則無法運作。

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp09.00.10.08"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  <strong>
    fetch(videoUrl) .then(function(response){' '}
    {
      // Process the response object.
    }
    );
  </strong>;
}

正式版播放器會提供多個版本的相同檔案,以支援不同的瀏覽器。它可以使用音訊和影片的個別檔案,讓系統根據語言設定選取音訊。

實際的程式碼也會有多個不同解析度的媒體檔案副本,以便因應不同的裝置功能和網路狀況。這類應用程式可使用區間要求或片段,以區塊方式載入及播放影片。這可在媒體播放時調整網路狀況。您可能聽過 DASH 或 HLS 等字詞,這兩種方法都能達成這項目標。完整討論這個主題超出本介紹的範圍。

處理回應物件

程式碼看起來幾乎完成,但媒體無法播放。我們需要從 Response 物件取得媒體資料,並傳送至 SourceBuffer

將資料從回應物件傳遞至 MediaSource 例項的常用方法,是從回應物件取得 ArrayBuffer,然後將其傳遞至 SourceBuffer。首先呼叫 response.arrayBuffer(),該函式會將承諾傳回至緩衝區。在我的程式碼中,我已將此承諾傳遞至第二個 then() 子句,並將其附加至 SourceBuffer

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp09.00.10.08"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  fetch(videoUrl)
    .then(function(response) {
      <strong>return response.arrayBuffer();</strong>
    })
    <strong>.then(function(arrayBuffer) {
      sourceBuffer.appendBuffer(arrayBuffer);
    });</strong>
}

呼叫 endOfStream()

所有 ArrayBuffers 都附加之後,如果沒有其他媒體資料,請呼叫 MediaSource.endOfStream()。這會將 MediaSource.readyState 變更為 ended,並觸發 sourceended 事件。

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp09.00.10.08"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  fetch(videoUrl)
    .then(function(response) {
      return response.arrayBuffer();
    })
    .then(function(arrayBuffer) {
      <strong>sourceBuffer.addEventListener('updateend', function(e) {
        if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
          mediaSource.endOfStream();
        }
      });</strong>
      sourceBuffer.appendBuffer(arrayBuffer);
    });
}

最終版本

以下是完整程式碼範例。希望您已瞭解一些關於媒體來源擴充功能的資訊。

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
  console.log('The Media Source Extensions API is not supported.');
}

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp09.00.10.08"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  fetch(videoUrl)
    .then(function (response) {
      return response.arrayBuffer();
    })
    .then(function (arrayBuffer) {
      sourceBuffer.addEventListener('updateend', function (e) {
        if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
          mediaSource.endOfStream();
        }
      });
      sourceBuffer.appendBuffer(arrayBuffer);
    });
}

意見回饋