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

François Beaufort
François Beaufort

Un inicio de reproducción más rápido implica que más personas mirarán tu video o escucharán tu audio. Es un dato conocido. En este artículo, exploraré las técnicas que puedes usar para acelerar la reproducción de audio y video mediante la carga previa activa de recursos según tu caso de uso.

Créditos: copyright Blender Foundation | www.blender.org .

Describiré tres métodos para precargar archivos multimedia. Comenzaremos con las ventajas y desventajas.

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

Atributo de precarga de video

Si la fuente de video es un archivo único alojado en un servidor web, te recomendamos que uses el atributo preload del video para indicarle al navegador cuanta información o contenido debe cargar previamente. Esto significa que las extensiones de fuente de medios (MSE) no son compatibles con preload.

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

Configurar el atributo preload en metadata indica que no se espera que el usuario necesite el video, pero que es conveniente recuperar sus metadatos (dimensiones, lista de pistas, duración, etcétera). Ten en cuenta que, a partir de Chrome 64, el valor predeterminado para preload es metadata. (Antes 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 para que se pueda completar la reproducción sin necesidad de detener el almacenamiento en búfer adicional.

<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 salvedades. Como esta es solo una pista, el navegador puede ignorar por completo el atributo preload. Hasta el momento de la redacción, estas son algunas reglas aplicadas en Chrome:

  • Cuando el Ahorro de datos está habilitado, 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 celular (2G, 3G y 4G), Chrome fuerza el valor de preload a metadata.

Sugerencias

Si tu sitio web contiene muchos recursos de video en el mismo dominio, te recomendaría que configures el valor de preload en metadata o que definas el atributo poster y configures preload en none. De esa manera, evitarías alcanzar la cantidad máxima de conexiones HTTP con el mismo dominio (6 según la especificación HTTP 1.1), lo que puede bloquear la carga de recursos. Ten en cuenta que esto también puede mejorar la velocidad de la página si los videos no forman parte de la experiencia del usuario principal.

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

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

Precargar video completo

A continuación, te mostramos cómo precargar un video completo en tu sitio web para que, cuando tu código JavaScript solicite recuperar contenido de video, se lea desde la caché, ya que es posible que el navegador ya haya almacenado en caché el recurso. Si la solicitud de precarga no ha terminado aún, 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>

Como un elemento de video consumirá el recurso precargado en el ejemplo, el valor del vínculo de carga previa as es video. Si fuera un elemento de audio, sería as="audio".

Precarga 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 JavaScript de ECM, consulta Conceptos básicos de ECM.

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 la compatibilidad de 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 HTTP Range, pero esto 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, están diseñadas para controlarlo.

<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 tienes el control de toda la experiencia de almacenamiento en búfer de contenido multimedia, te sugiero que consideres el nivel de batería del dispositivo, la preferencia del usuario del "modo de ahorro de datos" y la información de la red cuando pienses en la precarga.

Reconocimiento de batería

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

Inhabilita la precarga o al menos precarga un video de menor resolución cuando el dispositivo se está quedando sin batería.

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 "Ahorro de datos"

Usa el encabezado de solicitud de sugerencia de cliente Save-Data para entregar aplicaciones rápidas y livianas a los usuarios que habilitaron el modo "ahorro de datos" en su navegador. Mediante la identificación de este encabezado de 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 ofrecer aplicaciones rápidas y livianas con Save-Data para obtener más información.

Carga inteligente basada en la información de la red

Te recomendamos que revises navigator.connection.type antes de la carga previa. Cuando se establece en cellular, puedes evitar la carga previa y advertir a los usuarios que su operador de red móvil podría cobrar por el ancho de banda, y solo comenzar la reproducción automática del contenido almacenado en caché anteriormente.

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 el ejemplo de información de red para aprender a reaccionar también a los cambios de red.

Almacena previamente en caché múltiples primeros segmentos

Ahora bien, ¿qué sucede si quiero precargar de forma especulativa parte del contenido multimedia sin saber qué elemento multimedia elegirá el usuario? Si el usuario se encuentra en una página web que contiene 10 videos, es probable que tengamos suficiente memoria para obtener un archivo de segmento de cada uno, pero definitivamente no deberíamos crear 10 elementos <video> ocultos y 10 objetos MediaSource, y comenzar a proporcionar esos datos.

En el siguiente ejemplo de dos partes, se muestra cómo almacenar en caché previamente varios primeros segmentos de video con la API de Cache, 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 caché desde el objeto window.

Recuperación y almacenamiento 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 manualmente un objeto Response, ya que la API de Cache aún no admite respuestas Range. Ten en cuenta que llamar a networkResponse.arrayBuffer() recupera todo el contenido de la respuesta a la vez en la memoria del renderizador, por lo que te recomendamos que uses rangos pequeños.

A modo de referencia, modifiqué parte del ejemplo anterior para guardar las solicitudes de rango HTTP en la caché previa del 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 haga clic en un botón de reproducción, recuperaremos 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, simplemente los obtenemos de la red. Ten en cuenta que los navegadores y los usuarios pueden decidir borrar la caché.

Como se vio antes, usamos la MSE para enviar ese primer segmento de video al 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 rango con un trabajador de servicio

Ahora bien, ¿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 que todo el video se almacene en la memoria del procesador, ya que la API de Cache aún todavía no admite las respuestas de Range.

Así que, veamos cómo interceptar estas solicitudes y mostrar una respuesta Range personalizada de 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 esto solo me da un controlador para el archivo, mientras que response.arrayBuffer() lleva todo el archivo a la memoria del procesador.

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

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