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

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

Anna Beaufort
François Beaufort

Szybsze odtwarzanie oznacza, że więcej osób ogląda Twój film lub słucha dźwięku. To znany fakt. W tym artykule przedstawię techniki, które możesz wykorzystać do przyspieszenia odtwarzania dźwięku i wideo przez aktywne wstępne wczytywanie zasobów zależnie od konkretnego przypadku użycia.

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

Omówię trzy metody wstępnego wczytywania plików multimedialnych, zaczynając od ich wad i zalet.

To świetnie... Ale...
Atrybut wstępnego wczytywania wideo Prosty w użyciu dla unikalnego pliku hostowanego na serwerze WWW. Przeglądarki mogą całkowicie ignorować atrybut.
Pobieranie zasobów rozpoczyna się po całkowitym wczytaniu i przeanalizowaniu dokumentu HTML.
Rozszerzenia źródła multimediów (MSE) ignorują atrybut preload w elementach multimedialnych, ponieważ aplikacja jest odpowiedzialna za dostarczanie multimediów do MSE.
Wstępne wczytywanie linków Wymusza, aby przeglądarka wysyłała żądanie dotyczące zasobu wideo bez blokowania zdarzenia onload dokumentu. Żądania zakresu HTTP nie są zgodne.
Zgodny z MSE i segmentami plików. Powinien być używany tylko w przypadku małych plików multimedialnych (< 5 MB) przy pobieraniu pełnych zasobów.
Buforowanie ręczne Pełna kontrola Odpowiedzialność za obsługę złożonych błędów należy do obowiązków witryny.

Atrybut wstępnego wczytywania filmu

Jeśli źródłem filmu jest unikalny plik hostowany na serwerze WWW, możesz użyć atrybutu wideo preload, aby poinformować przeglądarkę o tym, ile informacji lub treści należy wstępnie wczytać. Oznacza to, że rozszerzenia Media Source Extensions (MSE) nie są zgodne z preload.

Pobieranie zasobów rozpocznie się dopiero po całkowitym wczytaniu i przeanalizowaniu początkowego dokumentu HTML (np. po uruchomieniu zdarzenia DOMContentLoaded), podczas gdy zupełnie inne zdarzenie load zostanie uruchomione dopiero po tym, jak zasób zostanie pobrany.

Ustawienie atrybutu preload na metadata oznacza, że użytkownik nie potrzebuje filmu, ale pobranie jego metadanych (wymiarów, listy utworów, czasu trwania itd.) jest pożądane. Pamiętaj, że od Chrome 64 domyślna wartość preload to metadata. (wcześniej 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 buforować wystarczającą ilość danych do pełnego odtwarzania bez konieczności zatrzymywania buforowania.

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

Istnieją jednak pewne zastrzeżenia. Jest to tylko wskazówka, dlatego przeglądarka może całkowicie ignorować atrybut preload. Poniżej przedstawiamy kilka reguł obowiązujących w Chrome:

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

Wskazówki

Jeśli Twoja witryna zawiera wiele zasobów wideo w tej samej domenie, zalecamy ustawienie wartości preload na metadata lub zdefiniowanie atrybutu poster oraz ustawienie preload na none. Dzięki temu unikniesz osiągnięcia maksymalnej liczby połączeń HTTP z tą samą domeną (6 zgodnie ze specyfikacją HTTP 1.1), przez co unikniesz ładowania zasobów. Pamiętaj, że może to też poprawić szybkość działania, jeśli filmy nie są głównym elementem Twojej działalności.

Jak opisano w innych artykułach, wstępne wczytywanie linku to pobieranie deklaratywne, które umożliwia zmuszenie przeglądarki 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ą skutecznie bezwładne, dopóki nie zostaną bezpośrednio odwołujące się do nich w DOM, JavaScript lub CSS.

Wstępne wczytywanie różni się od pobierania z wyprzedzeniem tym, że koncentruje się na bieżącej nawigacji i pobiera zasoby z priorytetem na podstawie ich typu (skrypt, stylu, czcionki, filmu, audio itp.). Powinna służyć do rozgrzewania pamięci podręcznej przeglądarki na potrzeby bieżących sesji.

Wstępnie wczytuj cały film

Poniżej opisujemy, jak wstępnie wczytać w witrynie cały film, aby gdy JavaScript wysyłał żądanie pobrania treści wideo, został on odczytany z pamięci podręcznej, ponieważ zasób mógł już być w pamięci podręcznej przeglądarki. Jeśli żądanie wstępnego wczytywania jeszcze się nie zakończyło, nastąpi zwykłe pobieranie sieciowe.

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

Wstępnie wczytywany zasób będzie wykorzystywany przez element wideo w tym przykładzie, dlatego wartość linku wstępnego wczytywania as wynosi video. Gdyby to był element audio, byłby to as="audio".

Wstępnie wczytuj pierwszy segment

Poniższy przykład pokazuje, jak wstępnie wczytać pierwszy segment filmu za pomocą właściwości <link rel="preload"> i używać go z rozszerzeniami źródła multimediów. Jeśli nie znasz interfejsu MSE JavaScript API, zapoznaj się z podstawowymi informacjami o MSE.

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

<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

Możesz sprawdzić obsługę różnych typów as w przypadku elementu <link rel=preload>, korzystając z tych fragmentów:

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 skryptów service worker, zobaczmy, jak ręcznie buforować film za pomocą MSE. W przykładzie poniżej założono, że Twój serwer WWW obsługuje żądania HTTP Range, ale wygląda to bardzo podobnie w przypadku segmentów plików. Pamiętaj, że niektóre biblioteki oprogramowania pośredniczącego, takie jak Google Shaka Player, JW Player i Video.js, zostały zaprojektowane tak, aby obsługiwać je 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>

co należy wziąć pod uwagę

Masz teraz kontrolę nad całym procesem buforowania multimediów, dlatego przy wstępnym wczytywaniu weź pod uwagę poziom baterii urządzenia, ustawienia użytkownika w trybie oszczędzania danych oraz informacje o sieci.

Świadomość dotycząca baterii

Zanim zaczniesz rozważać wstępne wczytywanie filmu, weź pod uwagę poziom naładowania baterii w urządzeniach użytkowników. Pozwoli to wydłużyć czas pracy na baterii przy niskim poziomie naładowania baterii.

Wyłącz wstępne wczytywanie filmów lub przynajmniej wstępne wczytywanie filmów w niższej rozdzielczości, gdy bateria urządzenia się wyczerpuje.

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 funkcji „Oszczędzanie danych”

Użyj nagłówka żądania wskazówki dotyczącej klienta Save-Data, aby dostarczać szybkie i łatwe aplikacje użytkownikom, którzy włączyli w przeglądarce tryb „oszczędzania danych”. Dzięki zidentyfikowaniu tego nagłówka żądania aplikacja może dostosować i zapewnić wygodę użytkownikom o ograniczonych kosztach i wydajności.

Więcej informacji znajdziesz w artykule Przesyłanie szybkich i lekkich aplikacji z funkcją Save-Data.

Inteligentne wczytywanie na podstawie informacji o sieci

Przed wstępnym załadowaniem warto sprawdzić navigator.connection.type. Gdy ma wartość cellular, możesz uniemożliwić wstępne wczytywanie i poinformować użytkowników, że operator sieci komórkowej może pobierać opłaty za przepustowość łącza, i rozpocząć tylko automatyczne odtwarzanie treści znajdujących się w 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.
  }
}

Informacje o tym, jak reagować na zmiany w sieci, znajdziesz w przykładzie dotyczącym informacji o sieci.

Wstępnie zapisuj wiele pierwszych segmentów w pamięci podręcznej

A co, jeśli chcę spekulacyjnie wczytać treści multimedialne, nie wiedząc, który produkt ostatecznie wybiorę? Jeśli użytkownik przegląda stronę z 10 filmami, prawdopodobnie mamy wystarczająco dużo pamięci, żeby pobrać po jednym pliku z każdego z nich. Zdecydowanie nie powinniśmy jednak tworzyć 10 ukrytych elementów <video> i 10 obiektów MediaSource, a potem przekazywać te dane.

Dwuczęściowy przykład poniżej pokazuje, jak zapisać w pamięci podręcznej wiele pierwszych segmentów filmów za pomocą zaawansowanego i łatwego w obsłudze interfejsu Cache API. Podobne rozwiązanie można też osiągnąć za pomocą IndexedDB. Nie korzystamy jeszcze z mechanizmów Service Worker, ponieważ interfejs Cache API jest też dostępny z poziomu obiektu window.

Pobierz i pamięć podręczna

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

Pamiętaj, że jeśli użyję żądań HTTP Range, musiałbym ręcznie utworzyć obiekt Response, ponieważ interfejs Cache API jeszcze nie obsługuje odpowiedzi Range. Pamiętaj, że wywołanie metody networkResponse.arrayBuffer() pobiera całą treść odpowiedzi jednocześnie do pamięci mechanizmu renderowania, dlatego warto używać małych zakresów.

Część powyższego przykładu zmodyfikowałam tak, aby zapisać żądania zakresu HTTP w pamięci podręcznej wideo.

    ...
    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, pobierze pierwszy segment filmu dostępny w interfejsie Cache API, aby umożliwić natychmiastowe rozpoczęcie odtwarzania (jeśli jest dostępne). W przeciwnym razie pobieramy go z sieci. Pamiętaj, że przeglądarki i użytkownicy mogą wyczyścić pamięć podręczną.

Jak już wspomnieliśmy, używamy technologii MSE, aby przekazywać ten pierwszy segment do elementu wideo.

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 zakresu za pomocą skryptu service worker

A co, jeśli pobierzesz cały plik wideo i zapiszesz go w interfejsie Cache API? Gdy przeglądarka wysyła żądanie HTTP Range, na pewno nie chcesz umieszczać całego filmu w pamięci mechanizmu renderowania, ponieważ interfejs Cache API jeszcze nie obsługuje odpowiedzi Range.

Pokażę, jak przechwycić te żądania i zwrócić niestandardową odpowiedź Range z skryptu service worker.

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

Do odtworzenia tej wyciętej odpowiedzi użyłem polecenia response.blob(), ponieważ zapewnia to mi tylko uchwyt dla pliku, a response.arrayBuffer() przenosi cały plik do pamięci mechanizmu renderowania.

Mój niestandardowy nagłówek HTTP X-From-Cache może służyć do sprawdzenia, czy żądanie pochodzi z pamięci podręcznej, czy z sieci. Może być używany przez odtwarzacze, na przykład ShakaPlayer, aby ignorować czas odpowiedzi jako wskaźnik szybkości sieci.

Kompletne rozwiązanie dotyczące obsługi żądań Range znajdziesz w oficjalnej aplikacji multimedialnej, a w szczególności w jej pliku ranged-response.js.