Reproducción rápida con precarga de audio y video

Cómo acelerar la reproducción de contenido multimedia con la precarga activa de recursos

Francisco Beaufort
François Beaufort

Un inicio más rápido de la reproducción significa que más personas miran tu video o escuchan tu audio. Es un dato conocido. En este artículo, exploraremos técnicas que puedes usar para acelerar la reproducción de audio y video mediante la precarga activa de recursos según tu caso de uso.

Créditos: Derechos de autor de Blender Foundation | www.blender.org .

Describiré tres métodos para precargar archivos multimedia, comenzando con sus pros y contras.

Es genial... Pero…
Atributo de precarga de video Fácil de usar para un archivo único alojado en un servidor web. Es posible que los navegadores ignoren el atributo por completo
La recuperación de recursos comienza cuando el documento HTML se carga y analiza por completo.
Las extensiones de fuente de medios (MSE) ignoran el atributo preload en los elementos multimedia porque la app es responsable de proporcionar este tipo de contenido al MSE.
Precarga del vínculo Hace que el navegador realice una solicitud para un recurso de video sin bloquear el evento onload del documento. Las solicitudes de rango HTTP no son compatibles.
Es compatible con MSE y segmentos de archivos. Solo debe usarse para archivos multimedia pequeños (<5 MB) cuando se recuperan los recursos completos.
Almacenamiento en búfer manual Control total El manejo de errores complejos es responsabilidad del sitio web.

Atributo de precarga de video

Si la fuente de video es un archivo único alojado en un servidor web, te recomendamos usar el atributo de video preload para proporcionar una sugerencia al navegador sobre cuánta información o contenido debe precargar. Esto significa que las extensiones de fuente de medios (MSE) no son compatibles con preload.

La recuperación de recursos comenzará cuando se haya cargado y analizado por completo el documento HTML inicial (p.ej., se activó el evento DOMContentLoaded), mientras que el evento load muy diferente se activará cuando se haya recuperado el recurso.

Establecer el atributo preload en metadata indica que no se espera que el usuario necesite el video, pero que se recomienda recuperar sus metadatos (dimensiones, lista de pistas, duración, etc.). Ten en cuenta que, a partir de Chrome 64, el valor predeterminado para preload es metadata. (era 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>

Establecer el atributo preload en auto indica que el navegador puede almacenar en caché suficientes datos como para completar la reproducción sin necesidad de detener el almacenamiento en búfer.

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

Sin embargo, hay algunas advertencias. Como esta es solo una sugerencia, es posible que el navegador ignore por completo el atributo preload. Al momento de la redacción, estas son algunas reglas que se aplican en Chrome:

  • Cuando está habilitado el Ahorro de datos, Chrome fuerza el valor preload a none.
  • En Android 4.3, Chrome fuerza el valor preload a none debido a un error de Android.
  • En una conexión móvil (2G, 3G y 4G), Chrome fuerza el valor preload a metadata.

Sugerencias

Si tu sitio web contiene muchos recursos de video en el mismo dominio, te recomendamos establecer el valor preload en metadata o definir el atributo poster y establecer preload en none. De esta manera, evitarás alcanzar la cantidad máxima de conexiones HTTP hacia el mismo dominio (6 según la especificación HTTP 1.1) que puede colgar la carga de recursos. Ten en cuenta que esto también puede mejorar la velocidad de la página si los videos no son parte de la experiencia principal del usuario.

Como se abarca en otros artículos, la precarga de vínculos es una recuperación declarativa que te permite forzar al navegador a realizar una solicitud de un recurso sin bloquear el evento load y mientras la página se descarga. Los recursos que se cargan a través de <link rel="preload"> se almacenan de forma local en el navegador y se mantienen inertes hasta que se hace referencia a ellos de forma explícita en el DOM, JavaScript o CSS.

La precarga es diferente de la carga previa en que se centra en la navegación actual y recupera recursos con prioridad según el tipo (secuencia de comandos, estilo, fuente, video, audio, etcétera). Se debe usar para preparar la caché del navegador para las sesiones actuales.

Precargar el video completo

Aquí te mostramos cómo precargar un video completo en tu sitio web para que, cuando tu JavaScript solicite recuperar contenido de video, se lea desde la caché, ya que el navegador puede haber almacenado el recurso en caché. Si la solicitud de precarga aún no ha finalizado, se realizará una recuperación de red normal.

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

Debido a que un elemento de video consumirá el recurso precargado en el ejemplo, el valor del vínculo de precarga as es video. Si se tratara de un elemento de audio, sería as="audio".

Precargar el primer segmento

En el siguiente ejemplo, se muestra cómo precargar el primer segmento de un video con <link rel="preload"> y usarlo con extensiones de fuente de medios. Si no estás familiarizado con la API de MSE JavaScript, consulta los conceptos básicos de MSE.

Para simplificar, supongamos que todo el video se dividió en archivos más pequeños, como file_1.webm, file_2.webm, file_3.webm, etcétera.

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

Asistencia

Puedes detectar compatibilidad con varios tipos de as para <link rel=preload> con los siguientes fragmentos:

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

Almacenamiento en búfer manual

Antes de profundizar en la API de Cache y los service workers, veamos cómo almacenar en búfer un video de forma manual con ECM. En el siguiente ejemplo, se supone que tu servidor web admite solicitudes Range HTTP, pero sería bastante similar con los segmentos de archivos. Ten en cuenta que algunas bibliotecas de middleware, como el reproductor Shaka de Google, el reproductor JW y Video.js, se crearon para controlar esto por ti.

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

Consideraciones

Como ahora controlas toda la experiencia de almacenamiento en búfer de contenido multimedia, te sugerimos que consideres el nivel de batería del dispositivo, las preferencias del usuario en el modo de ahorro de datos y la información de red cuando pienses en la precarga.

Conocimiento de la batería

Ten en cuenta el nivel de batería de los dispositivos de los usuarios antes de pensar en precargar un video. De esta manera, se conservará la duración de la batería cuando el nivel de energía sea bajo.

Inhabilita la precarga o, al menos, precarga un video de menor resolución cuando se agote la batería del dispositivo.

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

Detecta el "Ahorro de datos"

Usa el encabezado Save-Data de la solicitud de sugerencias del cliente para entregar aplicaciones rápidas y livianas a los usuarios que habilitaron el modo de "ahorro de datos" en su navegador. Mediante la identificación de este encabezado de la solicitud, tu aplicación puede personalizar y ofrecer una experiencia del usuario optimizada a los usuarios con limitaciones de costo y rendimiento.

Consulta Cómo entregar aplicaciones rápidas y livianas con Save-Data para obtener más información.

Carga inteligente basada en información de la red

Te recomendamos que verifiques navigator.connection.type antes de realizar la precarga. Cuando está configurado en cellular, puedes evitar la precarga y avisarles a los usuarios que es posible que su operador de red móvil esté cobrando por el ancho de banda y solo iniciar la reproducción automática del contenido previamente almacenado en caché.

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

Consulta la muestra de información de red para aprender a reaccionar a los cambios de red.

Prealmacena en caché varios primeros segmentos

¿Qué sucede si quiero precargar especulativamente contenido multimedia sin saber qué contenido elegirá el usuario? Si el usuario está en una página web que contiene 10 videos, es probable que tengamos suficiente memoria para recuperar un archivo de segmento de cada uno, pero definitivamente no deberíamos crear 10 elementos <video> ocultos ni 10 objetos MediaSource ni comenzar a proporcionar esos datos.

En el siguiente ejemplo de dos partes, se muestra cómo almacenar en caché varios primeros segmentos de video con la API de caché potente y fácil de usar. Ten en cuenta que también se puede lograr algo similar con IndexedDB. Aún no usamos service workers, ya que también se puede acceder a la API de Cache desde el objeto window.

Recuperar y almacenar en caché

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

Ten en cuenta que si usara solicitudes HTTP Range, tendría que volver a crear un objeto Response de forma manual, ya que la API de Cache no admite respuestas Range aún. Ten en cuenta que llamar a networkResponse.arrayBuffer() recupera todo el contenido de la respuesta de una sola vez en la memoria del procesador, por lo que te recomendamos usar rangos pequeños.

A modo de referencia, modifiqué parte del ejemplo anterior para guardar las solicitudes del rango HTTP en la caché previa de video.

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

Reproducir video

Cuando un usuario hace clic en un botón de reproducción, obtenemos el primer segmento de video disponible en la API de Cache para que la reproducción comience de inmediato, si está disponible. De lo contrario, solo los recuperaremos de la red. Ten en cuenta que los navegadores y los usuarios pueden decidir borrar la Caché.

Como se vio antes, usamos ECM para ingresar ese primer segmento de video en el elemento de 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.
      });
    }
  });
}

Crea respuestas de Range con un service worker

¿Qué sucede si recuperaste un archivo de video completo y lo guardaste en la API de Cache? Cuando el navegador envía una solicitud HTTP Range, no quieres colocar el video completo en la memoria del procesador, ya que la API de Cache no admite las respuestas Range todavía.

Te mostraré cómo interceptar estas solicitudes y mostrar una respuesta Range personalizada desde un 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;
  }
}

Es importante tener en cuenta que usé response.blob() para recrear esta respuesta dividida, ya que simplemente me da un controlador para el archivo, mientras que response.arrayBuffer() lleva el archivo completo a la memoria del procesador.

Mi encabezado HTTP X-From-Cache personalizado se puede usar para saber si esta solicitud provino de la caché o de la red. Un jugador como ShakaPlayer lo puede usar para ignorar el tiempo de respuesta como un indicador de la velocidad de la red.

Consulta la app de contenido multimedia de muestra oficial y, en particular, su archivo ranged-response.js para obtener una solución completa sobre cómo controlar las solicitudes Range.