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

Como acelerar a reprodução de mídia fazendo o pré-carregamento ativo de recursos.

François Beaufort
François Beaufort

O início mais rápido da reprodução significa que mais pessoas estão assistindo seu vídeo ou ouvindo seu áudio. Isso é um fato conhecido. Neste artigo, vamos explorar técnicas que você pode usar para acelerar a reprodução de áudio e vídeo com o pré-carregamento ativo de recursos, dependendo do seu caso de uso.

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

Vou descrever três métodos de pré-carregar 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 único hospedado em um servidor da Web. Os navegadores podem ignorar completamente o atributo.
A busca de recursos começa quando o documento HTML é completamente carregado e analisado.
As extensões de mídia (MSE) ignoram o atributo preload em elementos de mídia porque o app é responsável por fornecer mídia para a MSE.
Pré-carregamento de links 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 MSE e segmentos de arquivo. Deve ser usado apenas para arquivos de mídia pequenos (<5 MB) ao buscar recursos completos.
Armazenamento em buffer manual Controle total O processamento de erros complexos é responsabilidade do site.

Atributo de pré-carregamento de vídeo

Se a origem do vídeo for um arquivo único hospedado em um servidor da Web, use o atributo preload para indicar ao navegador a quantidade de informações ou conteúdo a ser pré-carregado. Isso significa que as Extensões de origem de mídia (MSE) não são compatíveis com preload.

A busca de recursos só vai começar quando o documento HTML inicial for completamente carregado e analisado (por exemplo, o evento DOMContentLoaded foi acionado), enquanto o evento load muito diferente será acionado quando o recurso for realmente buscado.

A configuração do atributo preload como metadata indica que o usuário não precisa do vídeo, mas que é desejável buscar os metadados dele (dimensões, lista de faixas, duração etc.). 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>

A definição do atributo preload como auto indica que o navegador pode armazenar em cache dados suficientes para que a reprodução completa seja possível sem precisar parar para mais bufferização.

<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 essa é apenas uma dica, o navegador pode ignorar completamente o atributo preload. No momento da redação deste artigo, estas são algumas regras aplicadas no Chrome:

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

Dicas

Se o seu site tiver muitos recursos de vídeo no mesmo domínio, recomendo definir o valor de preload como metadata ou definir o atributo poster e definir preload como none. Dessa forma, você evita atingir o número máximo de conexões HTTP para 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 sua experiência principal do usuário.

Como explicado em outros artigos, o pré-carregamento de links é um fetch declarativo que permite forçar o navegador a fazer uma solicitação de um recurso sem bloquear o evento load e enquanto a página está sendo transferida. Os recursos carregados por <link rel="preload"> são armazenados localmente no navegador e são efetivamente inativos até serem referenciados explicitamente no DOM, JavaScript ou CSS.

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

Pré-carregar o vídeo completo

Veja como pré-carregar um vídeo completo no seu site para que, quando o JavaScript pedir para buscar o conteúdo do vídeo, ele seja lido do cache, já que o recurso pode já ter sido armazenado em cache pelo navegador. Se a solicitação de pré-carregamento ainda não tiver sido concluída, uma busca de rede regular vai 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 extensões de origem de mídia. Se você não conhece a API JavaScript MSE, consulte Noções básicas de MSE.

Para simplificar, vamos supor que o vídeo inteiro 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

É possível detectar o suporte a vários tipos de as para <link rel=preload> com os trechos 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');
}

Bufferização manual

Antes de mergulharmos na API Cache e nos service workers, vamos conferir como armazenar um vídeo manualmente com MSE. O exemplo abaixo pressupõe que seu servidor da Web ofereça suporte a solicitações HTTP Range, mas isso seria muito semelhante aos segmentos de arquivo. Algumas bibliotecas de middleware, como o Shaka Player do Google, o JW Player e o Video.js, foram criadas para lidar com isso.

<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 você agora tem controle de toda a experiência de bufferização de mídia, sugerimos que você considere o nível de bateria do dispositivo, a preferência do usuário "Modo de economia de dados" e as informações de rede ao pensar sobre o pré-carregamento.

Uso da bateria

Leve em conta o nível da bateria dos dispositivos dos usuários antes de pensar em pré-carregar 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, pré-carregue um vídeo de resolução mais baixa quando a bateria do dispositivo estiver acabando.

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 sugestão de cliente Save-Data para oferecer aplicativos rápidos e leves a usuários que ativaram o modo de "economia de dados" no navegador. Ao identificar esse cabeçalho de solicitação, o aplicativo pode personalizar e oferecer uma experiência otimizada para usuários com restrições de custo e desempenho.

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

Carregamento inteligente com base em informações de rede

Verifique navigator.connection.type antes do carregamento. Quando ele está definido como cellular, é possível impedir o pré-carregamento e avisar os usuários de que o operador de rede móvel pode estar cobrando pela largura de banda e iniciar apenas 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 às mudanças de rede.

Pré-cachear vários primeiros segmentos

E se eu quiser pré-carregar algum conteúdo de mídia sem saber qual o usuário vai escolher? Se o usuário estiver em uma página da Web que contém 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 pré-cachear vários primeiros segmentos de vídeo usando a API Cache, que é fácil de usar. 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, teria que recriar manualmente um objeto Response, já que a API Cache ainda não oferece suporte a respostas Range . Chamar networkResponse.arrayBuffer() busca todo o conteúdo da resposta de uma só vez na memória do renderizador. Por isso, use intervalos pequenos.

Para referência, modifiquei parte do exemplo acima para salvar solicitações de intervalo HTTP no pré-cache do 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;
    });

Iniciar vídeo

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

Como visto anteriormente, usamos o MSE para alimentar o primeiro segmento de vídeo para o 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 worker de serviço

E se você tiver buscado um arquivo de vídeo inteiro e o salvo na API Cache? Quando o navegador envia uma solicitação HTTP Range, você não quer colocar o vídeo inteiro na memória do renderizador, porque a API Cache ainda não oferece suporte a respostas Range .

Vou mostrar como interceptar essas solicitações e retornar uma resposta Range personalizada de um worker de serviço.

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 simplesmente me dá um identificador para o arquivo, enquanto response.arrayBuffer() traz todo o arquivo 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 player, como ShakaPlayer, para ignorar o tempo de resposta como um indicador da velocidade da rede.

Confira o Sample Media App oficial e, em particular, o arquivo ranged-response.js para uma solução completa sobre como processar solicitações Range.