Szybkie odtwarzanie z wstępnym wczytywaniem dźwięku i obrazu

Jak przyspieszyć odtwarzanie multimediów przez aktywne wstępne wczytywanie zasobów.

François Beaufort
François Beaufort

Szybsze rozpoczęcie odtwarzania oznacza, że więcej osób ogląda Twój film lub słucha Twojej muzyki. To znany fakt. W tym artykule omawiam techniki, które możesz stosować do przyspieszania odtwarzania dźwięku i obrazu przez aktywne wstępne wczytywanie zasobów w zależności od przypadku użycia.

Autorzy: copyright Blender Foundation | www.blender.org .

Opiszę 3 metody wstępnego wczytywania plików multimedialnych, zaczynając od ich zalet i wad.

To świetne... Ale...
Atrybut wstępnego wczytania filmu Prosty w użyciu w przypadku unikalnego pliku hostowanego na serwerze WWW. Przeglądarki mogą całkowicie zignorować ten atrybut.
Pobieranie zasobów rozpoczyna się, gdy dokument HTML został w pełni wczytany i przeanalizowany.
Rozszerzenia źródła multimediów (MSE) ignorują atrybut preload w elementach multimediów, ponieważ aplikacja odpowiada za dostarczanie multimediów do MSE.
Przelewanie linków Wymusza na przeglądarce wysłanie żądania zasobu wideo bez blokowania zdarzenia onload dokumentu. Żądania zakresu HTTP nie są zgodne.
Zgodność z MSE i segmentami plików. Należy używać tylko w przypadku małych plików multimedialnych (mniejszych niż 5 MB) podczas pobierania pełnych zasobów.
Ręczne buforowanie Pełna kontrola Za obsługę złożonych błędów odpowiada właściciel witryny.

Atrybut wstępnego wczytania filmu

Jeśli źródło filmu to unikalny plik hostowany na serwerze internetowym, warto użyć atrybutu video preload, aby przekazać przeglądarce wskazówkę dotyczącą ile informacji lub treści ma zostać wstępnie załadowane. Oznacza to, że rozszerzenia źródła multimediów (MSE) są niezgodne z preload.

Pobieranie zasobów rozpocznie się dopiero po całkowitym załadowaniu i przeanalizowaniu początkowego dokumentu HTML (np. po wyzwoleniu zdarzenia DOMContentLoaded), a zdarzenie load zostanie wywołane dopiero po faktycznym pobraniu zasobu.

Ustawienie atrybutu preload na metadata wskazuje, że użytkownik nie potrzebuje filmu, ale pobieranie jego metadanych (wymiarów, listy utworów, czasu trwania itp.) jest pożądane. Pamiętaj, że od wersji Chrome 64 wartość domyślna dla preload to metadata. (wcześniej było to 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>

Ustawienie atrybutu preload na auto oznacza, że przeglądarka może przechowywać w pamięci podręcznej wystarczającą ilość danych, aby umożliwić odtwarzanie bez konieczności zatrzymywania się na dalsze buforowanie.

<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>

Są jednak pewne wyjątki. Ponieważ jest to tylko sugestia, przeglądarka może całkowicie zignorować atrybut preload. W momencie pisania tego artykułu w Chrome obowiązywały następujące reguły:

  • Gdy Oszczędzanie danych jest włączone, Chrome wymusza wartość preload na none.
  • W Androidzie 4.3 Chrome wymusza wartość preload na none z powodu błędu w Androidzie.
  • W przypadku połączenia komórkowego (2G, 3G i 4G) Chrome wymusza wartość preload na metadata.

Wskazówki

Jeśli Twoja witryna zawiera wiele zasobów wideo w tej samej domenie, zalecam ustawienie wartości atrybutu preload na metadata lub zdefiniowanie atrybutu poster i ustawienie wartości atrybutu preload na none. W ten sposób unikniesz osiągnięcia maksymalnej liczby połączeń HTTP z tą samą domeną (6 według specyfikacji HTTP 1.1), co może spowodować zawieszenie wczytywania zasobów. Pamiętaj, że może to też poprawić szybkość strony, jeśli filmy nie są częścią głównego interfejsu użytkownika.

Jak omówiliśmy w innych artykułach, wstępny załadowanie linku to deklaratywny sposób pobierania, który pozwala zmusić przeglądarkę do wysłania żądania zasobu bez blokowania zdarzenia load i podczas pobierania strony. Zasoby wczytywane za pomocą <link rel="preload"> są przechowywane lokalnie w przeglądarce i są nieaktywne, dopóki nie zostaną wyraźnie odwołane w DOM, JavaScript lub CSS.

Ładowanie wstępne różni się od wczytywania wstępnego tym, że skupia się na bieżącej nawigacji i pobiera zasoby z uwzględnieniem ich typu (skrypt, styl, czcionka, film, dźwięk itp.). Należy go używać do rozgrzewania pamięci podręcznej przeglądarki na potrzeby bieżących sesji.

Wstępnie wczytać cały film

Oto, jak wstępnie załadować cały film w witrynie, aby pobieranie treści filmu przez kod JavaScript odbywało się z pamięci podręcznej, ponieważ zasób może już być w niej przechowywany przez przeglądarkę. Jeśli żądanie wstępnego wczytania nie zostało jeszcze zakończone, nastąpi zwykłe pobieranie z sieci.

<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>

Ponieważ załadowany wstępnie zasób będzie używany przez element wideo w tym przykładzie, wartość linku do wstępnego wczytania as to video. Jeśli jest to element audio, będzie to as="audio".

Wstępne wczytywanie pierwszego segmentu

Przykład poniżej pokazuje, jak załadować pierwszy segment filmu za pomocą <link rel="preload"> i użyć go z rozszerzeniami źródła multimediów. Jeśli nie znasz interfejsu MSE JavaScript API, zapoznaj się z artykułem Podstawy MSE.

Dla uproszczenia załóżmy, że cały film został podzielony na mniejsze pliki, takie jak file_1.webm, file_2.webm, file_3.webm itd.

<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>

Pomoc

Obsługę różnych typów as w przypadku <link rel=preload> możesz wykryć za pomocą poniższych fragmentów kodu:

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

Buforowanie ręczne

Zanim przejdziemy do interfejsu Cache API i usług pomocniczych, zobaczmy, jak ręcznie buforować film za pomocą MSE. Przykład poniżej zakłada, że Twój serwer WWW obsługuje żądania HTTP Range, ale w przypadku segmentów plików jest to bardzo podobne. Pamiętaj, że niektóre biblioteki pośrednie, takie jak Shaka Player, JW Player i Video.js, zostały zaprojektowane tak, aby wykonywać tę czynność za Ciebie.

<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>

Uwagi

Ponieważ masz teraz kontrolę nad całym procesem buforowania multimediów, rozważ, czy przy pobieraniu wstępnej treści warto wziąć pod uwagę poziom naładowania baterii, ustawienie „Tryb oszczędzania danych” i informacje o sieci.

Informacja o baterii

Zanim zdecydujesz się na wstępne wczytywanie filmu, weź pod uwagę poziom naładowania baterii urządzeń użytkowników. Pozwoli to wydłużyć czas pracy urządzenia, gdy poziom baterii jest niski.

Wyłącz wstępne wczytywanie lub przynajmniej wczytaj film w niższej rozdzielczości, gdy poziom baterii urządzenia jest niski.

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

Wykrywanie trybu „Oszczędzanie danych”

Używaj nagłówka żądania z wskazówką klienta Save-Data, aby dostarczać szybkie i lekkie aplikacje użytkownikom, którzy w swoim przeglądarce włączyli tryb „oszczędzania danych”. Dzięki identyfikacji tego nagłówka żądania aplikacja może dostosowywać i zapewniać optymalne wrażenia użytkownikom, którzy mają ograniczone koszty i wydajność.

Więcej informacji znajdziesz w artykule Tworzenie szybkich i lekkich aplikacji z użyciem funkcji oszczędzania danych.

Inteligentne ładowanie na podstawie informacji o sieci

Przed wstępnym wczytaniem warto sprawdzić navigator.connection.type. Jeśli opcja cellular jest ustawiona, możesz uniemożliwić wstępne wczytywanie i poinformować użytkowników, że ich operator sieci komórkowej może pobierać opłaty za przepustowość, a automatyczne odtwarzanie rozpocznie się dopiero po wcześniejszym wczytaniu treści z pamięci podręcznej.

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

Aby dowiedzieć się, jak reagować na zmiany w sieć, zapoznaj się z przykładowymi informacjami o sieci.

Wstępne buforowanie wielu pierwszych segmentów

Co zrobić, jeśli chcę wstępnie załadować niektóre treści multimedialne, nie wiedząc, które z nich użytkownik ostatecznie wybierze? Jeśli użytkownik znajduje się na stronie internetowej zawierającej 10 filmów, prawdopodobnie mamy wystarczająco dużo pamięci, aby pobrać po jednym pliku segmentu z każdego filmu, ale nie powinniśmy tworzyć 10 ukrytych elementów <video> i 10 obiektów MediaSource, aby zacząć przesyłać te dane.

Ten przykład w 2 częściach pokazuje, jak wstępnie przechowywać w pamięci podręcznej wiele pierwszych segmentów filmu za pomocą potężnego i łatwego w użyciu interfejsu Cache API. Pamiętaj, że podobne efekty można osiągnąć za pomocą IndexedDB. Nie używamy jeszcze usług w tle, ponieważ interfejs Cache API jest dostępny również z obiektu window.

Pobieranie i przechowywanie w pamięci podręcznej

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

Jeśli użyję żądań HTTP Range, muszę ręcznie odtworzyć obiekt Response, ponieważ interfejs Cache API jeszcze nie obsługuje odpowiedzi Range. Pamiętaj, że wywołanie networkResponse.arrayBuffer() pobiera całą zawartość odpowiedzi naraz do pamięci renderera, dlatego warto używać małych zakresów.

W celu ułatwienia Ci zrozumienia tego zagadnienia zmodyfikowałem część przykładu powyżej, aby zapytania o zakres HTTP były zapisywane w pamięci podręcznej filmu.

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

Odtwórz film

Gdy użytkownik kliknie przycisk odtwarzania, pobieramy pierwszy segment filmu dostępny w Cache API, aby odtwarzanie rozpoczęło się natychmiast, jeśli jest możliwe. W przeciwnym razie po prostu pobieramy go z sieci. Pamiętaj, że przeglądarki i użytkownicy mogą zdecydować się na wyczyszczenie bufora pamięci podręcznej.

Jak już wspomnieliśmy, do przesyłania pierwszego segmentu filmu do elementu wideo używamy 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.
      });
    }
  });
}

Tworzenie odpowiedzi z użyciem zakresu za pomocą usługi roboczej

Co zrobić, jeśli pobierzesz cały plik wideo i zapiszesz go w Cache API? Gdy przeglądarka wysyła żądanie HTTP Range, nie chcesz wczytywać całego filmu do pamięci renderera, ponieważ interfejs Cache API jeszcze nie obsługuje odpowiedzi Range.

Pokażę Ci, jak przechwycić te żądania i zwrócić z serwisu workera spersonalizowaną odpowiedź 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;
  }
}

Pamiętaj, że do odtworzenia tego elementu odpowiedzi w postaci sekcji użyłem funkcji response.blob(), ponieważ daje ona mi dostęp do pliku, podczas gdy funkcja response.blob() przenosi cały plik do pamięci renderera.response.arrayBuffer()

Z niestandardowego nagłówka HTTP X-From-Cache można się dowiedzieć, czy żądanie pochodzi z pamięci podręcznej czy z sieci. Może być używany przez odtwarzacz, taki jak ShakaPlayer, aby ignorować czas odpowiedzi jako wskaźnik szybkości sieci.

Aby dowiedzieć się, jak obsługiwać żądania Range, zapoznaj się z oficjalną przykładową aplikacją multimedialną, a w szczególności z pliku ranged-response.js.