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

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

François Beaufort
François Beaufort

播放開始時間越快,觀看影片或收聽音訊的使用者就越多。這是眾所皆知的事實。本文將探討可用於加速音訊和影片播放的技術,並根據用途主動預先載入資源。

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

我將說明三種預先載入媒體檔案的方法,並說明各項方法的優缺點。

太棒了... 不過...
影片預先載入屬性 適用於在網路伺服器上託管的專屬檔案,使用方式簡單。 瀏覽器可能會完全忽略該屬性。
資源擷取作業會在 HTML 文件完全載入並完成剖析後開始。
媒體來源擴充功能 (MSE) 會忽略媒體元素的 preload 屬性,因為應用程式負責為 MSE 提供媒體。
連結預先載入 強制瀏覽器要求影片資源,但不阻擋文件的 onload 事件。 不相容 HTTP Range 要求。
與 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 已套用以下規則:

  • 啟用數據節省模式時,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.
    }
  });
}

偵測「Data Saver」

使用 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 也能達到類似的效果。我們尚未使用服務工作者,因為 Cache API 也可以透過 window 物件存取。

擷取及快取

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.
      });
    }
  });
}

使用服務工作者建立 Range 回應

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

因此,讓我示範如何攔截這些要求,並從服務工作站傳回自訂的 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 要求的完整解決方案。