預先載入音訊和視訊,快速播放

如何主動預先載入資源,加快媒體播放速度。

François Beaufort
François Beaufort

播放開始速度越快,觀看影片或收聽音訊的使用者就越多。這是已知的錯誤。本文說明如何根據用途主動預先載入資源,藉此加快音訊和影片播放速度。

版權資訊:版權所有 Blender Foundation | www.blender.org

以下將說明預先載入媒體檔案的三種方法,首先從其優缺點開始。

太棒了... 不過...
影片預先載入屬性 用於網路伺服器託管的專屬檔案。 瀏覽器可能會完全忽略該屬性。
當 HTML 文件已完全載入並完成剖析時,系統就會開始擷取資源。
媒體來源擴充功能 (MSE) 會忽略媒體元素的 preload 屬性,因為應用程式負責為 MSE 提供媒體。
連結預先載入 強制瀏覽器發出影片資源要求,且不封鎖文件的 onload 事件。 HTTP 範圍要求不相容。
與 MSE 和檔案區隔相容。 擷取完整資源時,僅適用於小型媒體檔案 (小於 5 MB)。
手動緩衝處理 完整控制 複雜的錯誤處理方式是由網站負責維護。

影片預先載入屬性

如果影片來源是網路伺服器代管的專屬檔案,建議您使用影片 preload 屬性,向瀏覽器提供要預先載入的資訊或內容提示。這表示Media Source Extensions (MSE)preload 不相容。

只有在初始 HTML 文件已完全載入及剖析 (例如 DOMContentLoaded 事件已觸發) 時,系統才會開始擷取資源,而當實際擷取資源時,系統會觸發截然不同的 load 事件。

preload 屬性設為 metadata 表示使用者不需要使用影片,但最好擷取其中繼資料 (維度、曲目清單、時間長度等)。請注意,從 Chrome 64 開始,preload 的預設值是 metadata。(先前為 auto)。

<video id="video" preload="metadata" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

preload 屬性設為 auto 表示瀏覽器可能會快取足夠的資料,讓您不必停止播放,也能完成播放,而無需進一步緩衝。

<video id="video" preload="auto" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

但也有一些限制。由於這只是提示,瀏覽器可能會完全忽略 preload 屬性。在本文撰寫期間,以下是 Chrome 適用的部分規則:

  • 啟用 Data Saver 時,Chrome 會將 preload 值強制設為 none
  • 在 Android 4.3 中,Chrome 會因為 Android 錯誤而強制將 preload 值設為 none
  • 在行動網路連線 (2G、3G 和 4G) 上,Chrome 會將 preload 值強制設為 metadata

提示

如果網站在同一個網域中包含許多影片資源,建議您將 preload 值設為 metadata,或是定義 poster 屬性,並將 preload 設為 none。如此一來,就能避免達到相同網域的 HTTP 連線數量上限 (根據 HTTP 1.1 規格 6),導致資源無法載入。請注意,如果影片不是核心使用者體驗的一部分,這麼做也可能會提升網頁速度。

如同其他文章介紹連結預先載入是一種宣告式擷取功能,可讓您強制瀏覽器提出資源要求,而不必封鎖 load 事件,也能在網頁下載期間繼續提出要求。透過 <link rel="preload"> 載入的資源會儲存在瀏覽器本機中,且在 DOM、JavaScript 或 CSS 中明確參照前,這些資源會處於無效狀態。

預先載入與預先擷取的差異在於,前者著重於目前導覽,並根據資源類型 (指令碼、樣式、字型、影片、音訊等) 優先擷取資源。應用於為目前工作階段暖機瀏覽器快取。

預先載入完整影片

以下說明如何在網站上預先載入完整影片,這樣當 JavaScript 要求擷取影片內容時,系統就會從快取中讀取,因為瀏覽器可能已將資源快取。如果預先載入要求尚未完成,系統會執行一般網路擷取作業。

<link rel="preload" as="video" href="https://cdn.com/small-file.mp4">

<video id="video" controls></video>

<script>
  // Later on, after some condition has been met, set video source to the
  // preloaded video URL.
  video.src = 'https://cdn.com/small-file.mp4';
  video.play().then(() => {
    // If preloaded video URL was already cached, playback started immediately.
  });
</script>

由於預先載入的資源將由範例中的影片元素使用,因此 as 預先載入連結的值為 video。如果是音訊元素,則會是 as="audio"

預先載入第一個區段

以下範例說明如何使用 <link rel="preload"> 預先載入影片的第一個片段,並搭配媒體來源擴充功能使用。如果您不熟悉 MSE JavaScript API,請參閱 MSE 基本概念

為簡單起見,我們假設整部影片已分割成較小的檔案,例如 file_1.webmfile_2.webmfile_3.webm 等。

<link rel="preload" as="fetch" href="https://cdn.com/file_1.webm">

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // If video is preloaded already, fetch will return immediately a response
    // from the browser cache (memory cache). Otherwise, it will perform a
    // regular network fetch.
    fetch('https://cdn.com/file_1.webm')
    .then(response => response.arrayBuffer())
    .then(data => {
      // Append the data into the new sourceBuffer.
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch file_2.webm when user starts playing video.
    })
    .catch(error => {
      // TODO: Show "Video is not available" message to user.
    });
  }
</script>

支援

您可以使用下列程式碼片段,偵測 <link rel=preload> 是否支援各種 as 類型:

function preloadFullVideoSupported() {
  const link = document.createElement('link');
  link.as = 'video';
  return (link.as === 'video');
}

function preloadFirstSegmentSupported() {
  const link = document.createElement('link');
  link.as = 'fetch';
  return (link.as === 'fetch');
}

手動緩衝處理

在深入探討 Cache API 和服務工作站之前,建議您先瞭解如何使用 MSE 手動緩衝影片。以下範例假設您的網路伺服器支援 HTTP Range 要求,但這與檔案區段非常相似。請注意,Google 的 Shaka PlayerJW PlayerVideo.js 等某些中介軟體程式庫,都是為了處理這項問題而建構。

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // Fetch beginning of the video by setting the Range HTTP request header.
    fetch('file.webm', { headers: { range: 'bytes=0-567139' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      sourceBuffer.appendBuffer(data);
      sourceBuffer.addEventListener('updateend', updateEnd, { once: true });
    });
  }

  function updateEnd() {
    // Video is now ready to play!
    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);

    // Fetch the next segment of video when user starts playing the video.
    video.addEventListener('playing', fetchNextSegment, { once: true });
  }

  function fetchNextSegment() {
    fetch('file.webm', { headers: { range: 'bytes=567140-1196488' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      const sourceBuffer = mediaSource.sourceBuffers[0];
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch further segment and append it.
    });
  }
</script>

注意事項

由於您現在可控管整個媒體緩衝體驗,建議您在考慮預先載入時,考量裝置的電池電量、使用者的「省電模式」偏好設定和網路資訊。

電池感知

在考慮預先載入影片前,請先考量使用者裝置的電池電量。這麼做可在電量不足時延長電池續航力。

在裝置電力耗盡時停用預先載入或預先載入解析度較低的影片。

if ('getBattery' in navigator) {
  navigator.getBattery()
  .then(battery => {
    // If battery is charging or battery level is high enough
    if (battery.charging || battery.level > 0.15) {
      // TODO: Preload the first segment of a video.
    }
  });
}

偵測「節省數據用量」

使用 Save-Data 用戶端提示要求標頭,為已在瀏覽器中啟用「省用資料」模式的使用者,提供快速輕量應用程式。應用程式可透過識別這個要求標頭,為受成本和效能限制的使用者提供最佳化使用者體驗。

詳情請參閱運用 Save-Data 提供快速推送應用程式

根據網路資訊自動載入

建議您在預先載入前檢查 navigator.connection.type。設為 cellular 時,可防止預先載入,並建議使用者行動網路業者可能根據頻寬收費,而且只會開始播放先前快取的內容。

if ('connection' in navigator) {
  if (navigator.connection.type == 'cellular') {
    // TODO: Prompt user before preloading video
  } else {
    // TODO: Preload the first segment of a video.
  }
}

您也可以查看網路資訊範例,瞭解如何回應網路變更。

預先快取多個第一個區隔

現在,如果我想在不知道使用者最終會選擇的媒體部分的情況下,以推測性的方式預先載入某個媒體內容嗎?如果使用者正在瀏覽包含 10 部影片的網頁,我們可能有足夠的記憶體可從每部影片擷取一個片段檔案,但絕對不應建立 10 個隱藏的 <video> 元素和 10 個 MediaSource 物件,並開始提供該資料。

下方的兩個範例說明如何使用功能強大且操作簡單的 Cache API,預先快取多個影片的第一個片段。請注意,IndexedDB 也能達到類似的效果。我們尚未使用 Service Worker,因為也可以透過 window 物件存取 Cache API。

擷取並快取

const videoFileUrls = [
  'bat_video_file_1.webm',
  'cow_video_file_1.webm',
  'dog_video_file_1.webm',
  'fox_video_file_1.webm',
];

// Let's create a video pre-cache and store all first segments of videos inside.
window.caches.open('video-pre-cache')
.then(cache => Promise.all(videoFileUrls.map(videoFileUrl => fetchAndCache(videoFileUrl, cache))));

function fetchAndCache(videoFileUrl, cache) {
  // Check first if video is in the cache.
  return cache.match(videoFileUrl)
  .then(cacheResponse => {
    // Let's return cached response if video is already in the cache.
    if (cacheResponse) {
      return cacheResponse;
    }
    // Otherwise, fetch the video from the network.
    return fetch(videoFileUrl)
    .then(networkResponse => {
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, networkResponse.clone());
      return networkResponse;
    });
  });
}

請注意,使用 HTTP Range 要求時,必須手動重新建立 Response 物件,因為 Cache API「尚」尚未支援 Range 回應。請注意,呼叫 networkResponse.arrayBuffer() 會將回應的整個內容一次擷取到轉譯器記憶體中,因此建議您使用較小的範圍。

做為參考,我已修改上述範例的部分,將 HTTP 範圍要求儲存至影片友善快取。

    ...
    return fetch(videoFileUrl, { headers: { range: 'bytes=0-567139' } })
    .then(networkResponse => networkResponse.arrayBuffer())
    .then(data => {
      const response = new Response(data);
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, response.clone());
      return response;
    });

播放影片

使用者點選播放按鈕時,我們會擷取 Cache API 中可用的首個影片片段,以便立即開始播放 (如果可用)。否則,我們會直接從網路擷取。請注意,瀏覽器和使用者可能會決定清除「快取」

如先前所述,我們使用 MSE 將第一段影片傳送給影片元素。

function onPlayButtonClick(videoFileUrl) {
  video.load(); // Used to be able to play video later.

  window.caches.open('video-pre-cache')
  .then(cache => fetchAndCache(videoFileUrl, cache)) // Defined above.
  .then(response => response.arrayBuffer())
  .then(data => {
    const mediaSource = new MediaSource();
    video.src = URL.createObjectURL(mediaSource);
    mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

    function sourceOpen() {
      URL.revokeObjectURL(video.src);

      const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');
      sourceBuffer.appendBuffer(data);

      video.play().then(() => {
        // TODO: Fetch the rest of the video when user starts playing video.
      });
    }
  });
}

使用 Service Worker 建立範圍回應

假設您擷取整個影片檔案並儲存在 Cache API 中,瀏覽器傳送 HTTP Range 要求時,您絕對不希望將整個影片匯入轉譯器記憶體,因為 Cache API「尚」不支援 Range 回應。

接下來將說明如何攔截這些要求,並傳回來自 Service Worker 的自訂 Range 回應。

addEventListener('fetch', event => {
  event.respondWith(loadFromCacheOrFetch(event.request));
});

function loadFromCacheOrFetch(request) {
  // Search through all available caches for this request.
  return caches.match(request)
  .then(response => {

    // Fetch from network if it's not already in the cache.
    if (!response) {
      return fetch(request);
      // Note that we may want to add the response to the cache and return
      // network response in parallel as well.
    }

    // Browser sends a HTTP Range request. Let's provide one reconstructed
    // manually from the cache.
    if (request.headers.has('range')) {
      return response.blob()
      .then(data => {

        // Get start position from Range request header.
        const pos = Number(/^bytes\=(\d+)\-/g.exec(request.headers.get('range'))[1]);
        const options = {
          status: 206,
          statusText: 'Partial Content',
          headers: response.headers
        }
        const slicedResponse = new Response(data.slice(pos), options);
        slicedResponse.setHeaders('Content-Range': 'bytes ' + pos + '-' +
            (data.size - 1) + '/' + data.size);
        slicedResponse.setHeaders('X-From-Cache': 'true');

        return slicedResponse;
      });
    }

    return response;
  }
}

請注意,我使用 response.blob() 重新建立這個切片回應,因為 response.blob() 只會提供檔案的句柄,而 response.arrayBuffer() 會將整個檔案帶入轉譯器記憶體。

我可以利用我的自訂 X-From-Cache HTTP 標頭,判斷這項要求是來自快取還是網路。ShakaPlayer 等播放器可使用此值,忽略回應時間做為網路速度的指標。

請參閱官方的範例媒體應用程式,特別是其 ranged-response.js 檔案,瞭解如何處理 Range 要求的完整解決方案。