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

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

Франсуа Бофор
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 .