Reprodução rápida com pré-carregamento de áudio e vídeo

Como acelerar a reprodução de mídia pré-carregando recursos ativamente.

Francisco Beaufort
François Beaufort

Um início de reprodução mais rápido significa que mais pessoas assistindo ou ouvindo seu vídeo. Isso é um fato conhecido. Neste artigo, vamos conhecer técnicas que podem ser usadas para acelerar a reprodução de áudio e vídeo com o pré-carregamento ativo de recursos, dependendo do caso de uso.

Créditos: direitos autorais Blender Foundation | www.blender.org .

Vou descrever três métodos de pré-carregamento de arquivos de mídia, começando pelos prós e contras.

É ótimo... Mas…
Atributo de pré-carregamento de vídeo Simples de usar para um arquivo exclusivo hospedado em um servidor da Web. Os navegadores podem ignorar o atributo completamente.
A busca de recursos começa quando o documento HTML é completamente carregado e analisado.
As extensões de origem de mídia (MSE, na sigla em inglês) ignoram o atributo preload nos elementos de mídia, porque o app é responsável por fornecer mídia ao MSE.
Pré-carregamento do link Força o navegador a fazer uma solicitação de um recurso de vídeo sem bloquear o evento onload do documento. As solicitações de intervalo HTTP não são compatíveis.
Compatível com segmentos de arquivo e o MSE. Deve ser usado somente para arquivos de mídia pequenos (< 5 MB) ao buscar recursos completos.
Armazenamento em buffer manual Controle total O tratamento complexo de erros é responsabilidade do site.

Atributo de pré-carregamento de vídeo

Se a fonte do vídeo for um arquivo exclusivo hospedado em um servidor da Web, use o atributo preload de vídeo para fornecer uma dica ao navegador sobre a quantidade de informações ou conteúdo a ser pré-carregado. Isso significa que as extensões de origem de mídia (MSE, na sigla em inglês) não são compatíveis com preload.

A busca de recursos só será iniciada quando o documento HTML inicial tiver sido completamente carregado e analisado (por exemplo, o evento DOMContentLoaded for disparado), enquanto o evento load muito diferente será disparado quando o recurso for realmente buscado.

Definir o atributo preload como metadata indica que o usuário não precisará do vídeo, mas que a busca dos metadados (dimensões, lista de faixas, duração etc.) é desejável. A partir do Chrome 64, o valor padrão de preload é 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>

Definir o atributo preload como auto indica que o navegador pode armazenar em cache dados suficientes para concluir a reprodução sem exigir uma parada para mais armazenamento em buffer.

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

No entanto, há algumas ressalvas. Como isso é apenas uma dica, o navegador pode ignorar completamente o atributo preload. No momento em que este artigo foi escrito, estas são algumas regras aplicadas ao Chrome:

  • Quando a Economia de dados está ativada, o Chrome força o valor preload para none.
  • No Android 4.3, o Chrome força o valor preload para none devido a um bug do Android.
  • Em uma conexão celular (2G, 3G e 4G), o Chrome força o valor preload para metadata.

Dicas

Caso seu site tenha muitos recursos de vídeo no mesmo domínio, recomendamos que você defina o valor preload como metadata ou o atributo poster e defina preload como none. Dessa forma, você evitaria alcançar o número máximo de conexões HTTP com o mesmo domínio (6 de acordo com a especificação HTTP 1.1), o que pode travar o carregamento de recursos. Isso também pode melhorar a velocidade da página se os vídeos não fizerem parte da experiência principal do usuário.

Conforme abordado em outros artigos, o pré-carregamento de links é uma busca declarativa que permite forçar o navegador a fazer uma solicitação de um recurso sem bloquear o evento load e durante o download da página. Os recursos carregados por <link rel="preload"> são armazenados localmente no navegador e ficam efetivamente inertes até serem referenciados explicitamente no DOM, no JavaScript ou no CSS.

O pré-carregamento é diferente da pré-busca, já que se concentra na navegação atual e busca recursos com prioridade com base no tipo (script, estilo, fonte, vídeo, áudio etc.). Ela é usada para aquecer o cache do navegador para as sessões atuais.

Pré-carregar vídeo completo

Confira como pré-carregar um vídeo completo no seu site para que, quando o JavaScript solicitar a busca de conteúdo de vídeo, ele seja lido no cache, já que o recurso pode já ter sido armazenado no navegador. Se a solicitação de pré-carregamento ainda não tiver sido concluída, uma busca de rede normal acontecerá.

<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 o recurso pré-carregado será consumido por um elemento de vídeo no exemplo, o valor do link de pré-carregamento as é video. Se fosse um elemento de áudio, seria as="audio".

Pré-carregar o primeiro segmento

O exemplo abaixo mostra como pré-carregar o primeiro segmento de um vídeo com <link rel="preload"> e usá-lo com o Media Source Extensions. Se você não conhece a API MSE JavaScript, consulte os conceitos básicos do MSE.

Para simplificar, vamos presumir que todo o vídeo foi dividido em arquivos menores, como file_1.webm, file_2.webm, file_3.webm etc.

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

Suporte

Você pode detectar a compatibilidade com vários tipos de as para <link rel=preload> com os snippets abaixo:

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

Buffer manual

Antes de nos aprofundarmos na API Cache e nos service workers, veja como armazenar um vídeo em buffer manualmente com o MSE. O exemplo abaixo pressupõe que o servidor da Web oferece suporte a solicitações HTTP Range, mas isso seria muito semelhante com segmentos de arquivos. Algumas bibliotecas de middleware, como Shaka Player do Google, JW Player e Video.js, são criadas para lidar com isso para você.

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

considerações

Como agora você tem controle de toda a experiência de armazenamento em buffer de mídia, sugiro que considere o nível de bateria do dispositivo, a preferência do usuário do "Modo de economia de dados" e as informações da rede ao pensar no pré-carregamento.

Reconhecimento de bateria

Considere o nível de bateria dos dispositivos dos usuários antes de pensar no pré-carregamento de um vídeo. Isso preserva a duração da bateria quando o nível de energia está baixo.

Desative o pré-carregamento ou pelo menos o pré-carregamento de um vídeo com resolução mais baixa quando o dispositivo estiver ficando sem bateria.

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

Detectar "Economia de dados"

Use o cabeçalho de solicitação de dica do cliente Save-Data para fornecer aplicativos rápidos e leves aos usuários que ativaram o modo "economia de dados" no navegador. Ao identificar esse cabeçalho de solicitação, seu aplicativo pode personalizar e oferecer uma experiência otimizada ao usuário para usuários com limitações de custo e desempenho.

Consulte Como entregar aplicativos rápidos e leves com o recurso Save-Data para saber mais.

Carregamento inteligente com base nas informações da rede

Confira navigator.connection.type antes do pré-carregamento. Quando está definido como cellular, é possível impedir o pré-carregamento e informar aos usuários que a operadora de rede móvel pode estar cobrando pela largura de banda e apenas iniciar a reprodução automática do conteúdo armazenado em cache 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.
  }
}

Confira o exemplo de informações de rede para saber como reagir a mudanças de rede.

Pré-armazenar em cache vários primeiros segmentos

E se eu quiser pré-carregar especulativamente algum conteúdo de mídia sem saber qual mídia o usuário escolherá? Se o usuário estiver em uma página da Web que contenha 10 vídeos, provavelmente teremos memória suficiente para buscar um arquivo de segmento de cada um, mas não devemos criar 10 elementos <video> ocultos e 10 objetos MediaSource e começar a alimentar esses dados.

O exemplo de duas partes abaixo mostra como armazenar vários primeiros segmentos de vídeo em cache usando a avançada e fácil de usar API Cache. Observe que algo semelhante também pode ser alcançado com o IndexedDB. Ainda não estamos usando service workers porque a API Cache também pode ser acessada pelo objeto window.

Buscar e armazenar em cache

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

Se eu usasse solicitações HTTP Range, precisaria recriar manualmente um objeto Response, porque a API Cache não oferece suporte a respostas Range ainda. Lembre-se de que chamar networkResponse.arrayBuffer() busca todo o conteúdo da resposta de uma só vez na memória do renderizador. É por isso que convém usar intervalos pequenos.

Como referência, modifiquei parte do exemplo acima para salvar solicitações de intervalo HTTP no pré-cache de vídeo.

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

Assista ao vídeo

Quando um usuário clica em um botão de reprodução, buscamos o primeiro segmento do vídeo disponível na API Cache para que a reprodução comece imediatamente, se disponível. Caso contrário, vamos simplesmente buscá-lo na rede. É importante lembrar que os navegadores e usuários podem decidir limpar o cache.

Como visto antes, usamos o MSE para alimentar o primeiro segmento do vídeo no elemento de vídeo.

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

Criar respostas de intervalo com um service worker

E se você buscou um arquivo de vídeo inteiro e o salvou na API Cache? Quando o navegador envia uma solicitação HTTP Range, é recomendável não colocar todo o vídeo na memória do renderizador, porque a API Cache não oferece suporte a respostas Range ainda.

Vou mostrar como interceptar essas solicitações e retornar uma resposta Range personalizada de um 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;
  }
}

É importante observar que usei response.blob() para recriar essa resposta fragmentada, porque isso me dá um identificador para o arquivo, enquanto response.arrayBuffer() leva o arquivo inteiro para a memória do renderizador.

Meu cabeçalho HTTP X-From-Cache personalizado pode ser usado para saber se essa solicitação veio do cache ou da rede. Ele pode ser usado por um jogador como o ShakaPlayer para ignorar o tempo de resposta como um indicador da velocidade da rede.

Consulte o App de mídia de exemplo oficial e, em particular, o arquivo ranged-response.js (links em inglês) para ter uma solução completa de como lidar com solicitações Range.