Быстрое воспроизведение с предварительной загрузкой аудио и видео

Как ускорить воспроизведение мультимедиа за сче�� активной предварительной загрузки ресурсов.

Франсуа Бофор
François Beaufort

Более быстрое начало воспроизведения означает, что больше людей смотрят ваше видео или слушают аудио. Это известный факт . В этой статье я рассмотрю методы, которые вы можете использовать для ускорения воспроизведения аудио и видео за счет активной предварительной загрузки ресурсов в зависимости от вашего варианта использования.

Кредит��: авторские права Blender Foundation | www.blender.org .

Я опишу три метода предварительной загрузки медиафайло��, начиная с их плюсов и минусов.

Это здорово... Но...
Атрибут предварительной загрузки видео Простота использования для уникального файла, размещенного на веб-сервере. Браузеры могут полностью игнорировать этот атрибут.
Извлечение ресурсов начинается после полной загрузки и анализа HTML-документа.
Расширения источника мультимедиа (MSE) игнорируют атрибут preload в элементах мультимедиа, поскольку приложение отвечает за предоставление мультимедиа в MSE.
Предварительная загрузка ссылки Заставляет браузер выполнить запрос видеоресурса, не блокируя событие onload документа. Запросы диапазона HTTP несовместимы.
Совместим с MSE и сегментами файлов. Следует использовать только для небольших медиафайлов (<5 МБ) при получении полных ресурсов.
Ручная буферизация Полный контроль Ответственность за обработку сложных ошибок лежит на веб-сайте.

Атрибут предварительной загрузки видео

Если источником видео является уникальный файл, размещенный на веб-сервере, вы можете использовать атрибут preload видео, чтобы подсказать браузеру, какой объем информации или контента требуется предварительно загрузить . Это означает , что расширения источника мультимедиа (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 принудительно устанавливает значение preload в none из-за ошибки Android .
  • При сотовом соединении (2G, 3G и 4G) Chrome принудительно загружает значение preload в metadata .

Советы

Если ваш веб-сайт содержит много видеоресурсов в одном домене, я бы рекомендовал вам установить значение preload в metadata или определить атрибут poster и установить для preload none . Таким образом, вы избежите достижения максимального количества HTTP-соединений к одному и тому же домену (6 согласно спецификации HTTP 1.1), что может привест�� к зависанию загрузки ресурсов. Обратите внимание, что это также может повысить скорость страницы, если видео не является частью вашего основного пользовательского опыта.

Как описано в других статьях , предварительная загрузка ссылки — это декларативная выборка, которая позволяет заставить браузер выполнить запрос ресурса, не блокируя событие 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 preload равно video . Если бы это был элемент audio, это было бы as="audio" .

Предварительная загрузка первого сегмента

В приведенном ниже примере показано, как предварительно загрузить первый сегмент видео с помощью <link rel="preload"> и использовать его с расширениями источника мультимедиа. Если вы не знакомы с MSE JavaScript API, см. Основы MSE .

Для простоты предположим, что все видео разбито на более мелкие файлы, такие как file_1.webm , file_2.webm , file_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>

Поддерживать

Вы можете обнаружить поддержку различных as для <link rel=preload> с помощью фрагментов ниже:

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

Ручная буферизация

Прежде чем мы углубимся в API Cache и сервис-воркеров, давайте посмотрим, как вручную буферизовать видео с помощью MSE. В приведенном ниже примере предполагается, что ваш веб-сервер поддерживает запросы HTTP Range , но это будет очень похоже на сегменты файлов. Обратите внимание, что некоторые библиотеки промежуточного программного обеспечения, такие как Google Shaka Player , JW Player и Video.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 , чтобы доставлять быстрые и легкие приложения пользователям, которые включили режим «экономии данных» в своем браузере. Определив этот заголовок запроса, ваше приложение может настроить и предоставить оптимизированный пользовательский интерфейс для пользователей с ограниченными затратами и производительностью.

Дополнительную информацию см. в разделе «Доставка быстрых и легких приложений с помощью данных сохранения» .

Умная загрузка на основе сетевой информации

Возможно, вы захотите проверить 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. Мы пока не используем сервис-воркеров, поскольку API Cache также доступен из объекта 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 , поскольку API Cache пока не поддерживает ответы Range . Помните, что вызов networkResponse.arrayBuffer() сразу извлекает все содержимое ответа в память средства рендеринга, поэтому вам может потребоваться использовать небольшие диапазоны.

Для справки: я изменил часть приведенного выше примера, чтобы сохранять запросы HTTP Range в прекэш видео.

    ...
    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 для подачи этого первого сегмента видео в элемент video.

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

Создайте ответы диапазона с помощью сервисного работника

А что, если вы получили весь видеофайл и сохранили его в 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.arrayBuffer() переносит весь файл в память средства рендеринга.

Мой собственный HTTP-заголовок X-From-Cache можно использовать, чтобы узнать, пришел ли этот запрос из кеша или из сети. Его может использовать такой проигрыватель, как ShakaPlayer , чтобы игнорировать время ответа как индикатор скорости сети.

Ознакомьтесь с официальным примером медиа-приложения и, в частности, с его файлом ranged-response.js , чтобы получить полное решение по обработке запросов Range .