Lecture rapide avec préchargement audio et vidéo

Accélérez la lecture multimédia en préchargeant activement des ressources.

François Beaufort
François Beaufort

Un démarrage plus rapide de la lecture signifie que plus de spectateurs regardent votre vidéo ou écoutent votre contenu audio. C'est un fait connu. Dans cet article, je vais vous présenter les techniques que vous pouvez utiliser pour accélérer la lecture audio et vidéo en préchargeant activement des ressources en fonction de votre cas d'utilisation.

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

Je vais décrire trois méthodes de préchargement de fichiers multimédias, en commençant par leurs avantages et leurs inconvénients.

C'est super... Mais…
Attribut de préchargement vidéo Simple à utiliser pour un fichier unique hébergé sur un serveur Web. Les navigateurs peuvent ignorer complètement l'attribut.
La récupération de ressources commence lorsque le document HTML a été entièrement chargé et analysé.
Les extensions de source multimédia (MSE) ignorent l'attribut preload sur les éléments multimédias, car l'application est chargée de fournir des éléments multimédias à MSE.
Préchargement des liens Force le navigateur à envoyer une requête pour une ressource vidéo sans bloquer l'événement onload du document. Les requêtes HTTP Range ne sont pas compatibles.
Compatible avec MSE et les segments de fichiers. Ne doit être utilisé que pour les petits fichiers multimédias (<5 Mo) lors de la récupération de ressources complètes.
Mise en mémoire tampon manuelle Contrôle complet La gestion des erreurs complexes relève de la responsabilité du site Web.

Attribut de préchargement de la vidéo

Si la source vidéo est un fichier unique hébergé sur un serveur Web, vous pouvez utiliser l'attribut preload de la vidéo pour indiquer au navigateur la quantité d'informations ou de contenus à précharger. Cela signifie que les extensions de source multimédia (MSE) ne sont pas compatibles avec preload.

L'extraction de ressources ne commencera que lorsque le document HTML initial aura été complètement chargé et analysé (par exemple, lorsque l'événement DOMContentLoaded se déclenchera), tandis que l'événement load très différent se déclenchera lorsque la ressource aura été effectivement extraite.

Définir l'attribut preload sur metadata indique que l'utilisateur n'a pas besoin de la vidéo, mais que l'extraction de ses métadonnées (dimensions, liste de pistes, durée, etc.) est souhaitable. Notez qu'à partir de Chrome 64, la valeur par défaut de preload est metadata. (il était auparavant 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>

Définir l'attribut preload sur auto indique que le navigateur peut mettre en cache suffisamment de données pour que la lecture complète soit possible sans avoir à s'arrêter pour une mise en mémoire tampon ultérieure.

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

Toutefois, il existe quelques mises en garde. Comme il ne s'agit que d'un indice, le navigateur peut ignorer complètement l'attribut preload. Au moment de la rédaction de cet article, voici quelques règles appliquées dans Chrome:

  • Lorsque l'Économiseur de données est activé, Chrome force la valeur preload à none.
  • Sous Android 4.3, Chrome force la valeur preload à none en raison d'un bug Android.
  • Sur une connexion mobile (2G, 3G et 4G), Chrome force la valeur preload à metadata.

Conseils

Si votre site Web contient de nombreuses ressources vidéo sur le même domaine, je vous recommande de définir la valeur preload sur metadata ou de définir l'attribut poster et de définir preload sur none. Vous éviterez ainsi d'atteindre le nombre maximal de connexions HTTP au même domaine (6 selon la spécification HTTP 1.1), ce qui peut bloquer le chargement des ressources. Notez que cela peut également améliorer la vitesse de chargement des pages si les vidéos ne font pas partie de l'expérience utilisateur de base.

Comme expliqué dans d'autres articles, le préchargement de liens est une récupération déclarative qui vous permet de forcer le navigateur à effectuer une requête pour une ressource sans bloquer l'événement load et pendant le téléchargement de la page. Les ressources chargées via <link rel="preload"> sont stockées localement dans le navigateur et sont effectivement inactives jusqu'à ce qu'elles soient référencées explicitement dans le DOM, JavaScript ou CSS.

Le préchargement est différent du préchargement en ce sens qu'il se concentre sur la navigation en cours et extrait les ressources en priorité en fonction de leur type (script, style, police, vidéo, audio, etc.). Il doit être utilisé pour préchauffer le cache du navigateur pour les sessions en cours.

Précharger la vidéo complète

Voici comment précharger une vidéo complète sur votre site Web afin que, lorsque votre code JavaScript demande à extraire le contenu vidéo, il soit lu à partir du cache, car la ressource a peut-être déjà été mise en cache par le navigateur. Si la requête de préchargement n'est pas encore terminée, une récupération réseau régulière se produit.

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

Dans l'exemple, la ressource préchargée va être utilisée par un élément vidéo. La valeur du lien de préchargement as est donc video. S'il s'agit d'un élément audio, il s'agit de as="audio".

Précharger le premier segment

L'exemple ci-dessous montre comment précharger le premier segment d'une vidéo avec <link rel="preload"> et l'utiliser avec les extensions de source multimédia. Si vous ne connaissez pas l'API JavaScript MSE, consultez la section Principes de base de MSE.

Par souci de simplicité, supposons que la vidéo entière a été divisée en fichiers plus petits tels que 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>

Assistance

Vous pouvez détecter la prise en charge de différents types as pour <link rel=preload> avec les extraits ci-dessous:

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

Mise en mémoire tampon manuelle

Avant de nous intéresser à l'API Cache et aux service workers, voyons comment mettre en mémoire tampon manuellement une vidéo avec MSE. L'exemple ci-dessous suppose que votre serveur Web est compatible avec les requêtes HTTP Range, mais cela serait assez similaire avec les segments de fichiers. Notez que certaines bibliothèques de middleware telles que le lecteur Shaka de Google, JW Player et Video.js sont conçues pour gérer cela à votre place.

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

Remarques

Comme vous contrôlez désormais l'ensemble de l'expérience de mise en mémoire tampon des contenus multimédias, je vous suggère de prendre en compte le niveau de batterie de l'appareil, la préférence utilisateur "Mode économiseur de données" et les informations sur le réseau lorsque vous réfléchissez au préchargement.

Surveillance de l'état de la batterie

Tenez compte du niveau de batterie des appareils des utilisateurs avant de précharger une vidéo. Cela permet de préserver l'autonomie de la batterie lorsque le niveau d'alimentation est faible.

Désactivez le préchargement ou préchargez au moins une vidéo en basse résolution lorsque la batterie de l'appareil est faible.

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

Détecter "Économiseur de données"

Utilisez l'en-tête de requête d'indice client Save-Data pour fournir des applications rapides et légères aux utilisateurs qui ont activé le mode "Économie de données" dans leur navigateur. En identifiant cet en-tête de requête, votre application peut personnaliser et offrir une expérience utilisateur optimisée aux utilisateurs soumis à des contraintes de coût et de performances.

Pour en savoir plus, consultez Fournir des applications rapides et légères avec Save-Data.

Chargement intelligent basé sur les informations réseau

Vous pouvez vérifier navigator.connection.type avant le préchargement. Lorsque ce paramètre est défini sur cellular, vous pouvez empêcher le préchargement et avertir les utilisateurs que leur opérateur de réseau mobile peut facturer la bande passante, et ne lancer que la lecture automatique du contenu précédemment mis en cache.

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

Consultez l'exemple d'informations réseau pour découvrir comment réagir aux modifications du réseau.

Pré-cacher plusieurs premiers segments

Que faire si je souhaite précharger de manière spéculative du contenu multimédia sans savoir quel contenu multimédia l'utilisateur choisira ? Si l'utilisateur se trouve sur une page Web contenant 10 vidéos, nous disposons probablement de suffisamment de mémoire pour extraire un fichier de segment de chacune d'elles, mais nous ne devons certainement pas créer 10 éléments <video> masqués et 10 objets MediaSource, et commencer à alimenter ces données.

L'exemple en deux parties ci-dessous montre comment pré-mettre en cache plusieurs premiers segments de vidéo à l'aide de l'API Cache, puissante et facile à utiliser. Notez que vous pouvez également obtenir un résultat similaire avec IndexedDB. Nous n'utilisons pas encore de service workers, car l'API Cache est également accessible depuis l'objet window.

Récupérer et mettre en 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;
    });
  });
}

Notez que si j'utilisais des requêtes HTTP Range, je devrais recréer manuellement un objet Response, car l'API Cache n'est pas encore compatible avec les réponses Range. N'oubliez pas que l'appel de networkResponse.arrayBuffer() récupère l'intégralité du contenu de la réponse en une seule fois dans la mémoire du moteur de rendu. C'est pourquoi vous pouvez utiliser de petites plages.

Pour référence, j'ai modifié une partie de l'exemple ci-dessus pour enregistrer les requêtes de plage HTTP dans le préchargement de la vidéo.

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

Lire la vidéo

Lorsqu'un utilisateur clique sur un bouton de lecture, nous récupérons le premier segment de vidéo disponible dans l'API Cache afin que la lecture commence immédiatement, le cas échéant. Sinon, nous l'extrayons simplement du réseau. N'oubliez pas que les navigateurs et les utilisateurs peuvent décider de vider le cache.

Comme nous l'avons vu précédemment, nous utilisons MSE pour transmettre ce premier segment de vidéo à l'élément vidéo.

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

Créer des réponses de plage avec un service worker

Que se passe-t-il si vous avez extrait un fichier vidéo entier et l'avez enregistré dans l'API Cache ? Lorsque le navigateur envoie une requête HTTP Range, vous ne souhaitez certainement pas importer l'intégralité de la vidéo dans la mémoire du moteur de rendu, car l'API Cache n'est pas encore compatible avec les réponses Range.

Je vais donc vous montrer comment intercepter ces requêtes et renvoyer une réponse Range personnalisée à partir d'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;
  }
}

Il est important de noter que j'ai utilisé response.blob() pour recréer cette réponse segmentée, car cela me donne simplement un gestionnaire de fichier, tandis que response.arrayBuffer() importe l'intégralité du fichier dans la mémoire du moteur de rendu.

Mon en-tête HTTP X-From-Cache personnalisé peut être utilisé pour savoir si cette requête provient du cache ou du réseau. Un lecteur tel que ShakaPlayer peut l'utiliser pour ignorer le temps de réponse en tant qu'indicateur de la vitesse du réseau.

Consultez l'exemple d'application multimédia officiel, en particulier son fichier ranged-response.js, pour obtenir une solution complète sur la gestion des requêtes Range.