Lecture rapide avec préchargement audio et vidéo

Découvrez comment accélérer la lecture de contenus multimédias en préchargeant activement les ressources.

François Beaufort
François Beaufort

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

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

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

C'est génial... Mais…
Attribut de préchargement de la vidéo Simple à utiliser pour un fichier unique hébergé sur un serveur Web. Il est possible que les navigateurs ignorent complètement cet attribut.
L'extraction des ressources commence une fois que le document HTML a été entièrement chargé et analysé.
Les extensions Media Source (MSE) ignorent l'attribut preload sur les éléments multimédias, car l'application est chargée de fournir le contenu multimédia à MSE.
Préchargement des liens Oblige le navigateur à effectuer une requête de ressource vidéo sans bloquer l'événement onload du document. Les requêtes de plage HTTP ne sont pas compatibles.
Compatible avec les fichiers MSE et les segments de fichiers. À utiliser uniquement pour les petits fichiers multimédias (<5 Mo) lors de l'extraction de toutes les ressources.
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 vidéo preload pour indiquer au navigateur le volume d'informations ou de contenus à précharger. Cela signifie que les Media Source Extensions (MSE) ne sont pas compatibles avec preload.

L'extraction des ressources ne commence que lorsque le document HTML initial a été entièrement chargé et analysé (par exemple, lorsque l'événement DOMContentLoaded a été déclenché), tandis que l'événement très différent load est déclenché lorsque la ressource a effectivement été récupérée.

Définir l'attribut preload sur metadata indique que l'utilisateur n'est pas censé avoir besoin de la vidéo, mais que l'extraction de ses métadonnées (dimensions, liste des titres, durée, etc.) est souhaitable. Notez qu'à partir de Chrome 64, la valeur par défaut de preload est metadata. (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 permettre la lecture complète sans nécessiter d'arrêt pour une mise en mémoire tampon supplémentaire.

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

Il y a toutefois quelques mises en garde. Comme il s'agit d'un indice, il est possible que le navigateur ignore complètement l'attribut preload. Au moment de la rédaction de ce document, 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 sur none en raison d'un bug Android.
  • Sur une connexion au réseau 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, nous vous recommandons de définir la valeur preload sur metadata ou de définir l'attribut poster et preload sur none. Ainsi, vous éviterez d'atteindre le nombre maximal de connexions HTTP vers le même domaine (six selon la spécification HTTP 1.1), ce qui peut bloquer le chargement des ressources. Notez que cela peut également améliorer la vitesse des pages si les vidéos ne font pas partie de l'expérience utilisateur principale.

Comme indiqué dans d'autres articles, le préchargement de lien est une extraction déclarative qui vous permet de forcer le navigateur à effectuer une requête de ressource sans bloquer l'événement load 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 inertes jusqu'à ce qu'elles soient explicitement référencées 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 actuelle et récupère les ressources avec une 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 à récupérer un contenu vidéo, il soit lu à partir du cache, car la ressource peut avoir 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 standard a lieu.

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

Étant donné que la ressource préchargée sera utilisée par un élément vidéo de l'exemple, la valeur du lien de préchargement as est video. S'il s'agit d'un élément audio, ce serait 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 comment l'utiliser avec des extensions de source multimédia. Si vous ne connaissez pas l'API MSE JavaScript, consultez la page 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 de as pour <link rel=preload> à l'aide des 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 d'examiner en détail l'API Cache et les service workers, voyons comment mettre en mémoire tampon manuellement une vidéo avec MSE. L'exemple ci-dessous suppose que votre serveur Web accepte les requêtes HTTP Range, mais cela est assez similaire avec les segments de fichiers. Notez que certaines bibliothèques de middleware, telles que Shaka Player 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>

Points à prendre en compte

Comme vous contrôlez désormais l'ensemble de la mise en mémoire tampon du contenu multimédia, je vous recommande de tenir compte du niveau de batterie de l'appareil, des préférences utilisateur concernant le mode Économiseur de données et des informations réseau lorsque vous envisagez de précharger.

Détection de la batterie

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

Désactivez le préchargement ou au moins préchargez une vidéo de résolution inférieure lorsque l'appareil est à court de batterie.

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 l'"économiseur de données"

Utilisez l'en-tête de requête d'optimisation du client Save-Data pour fournir des applications rapides et légères aux utilisateurs qui ont activé le mode "Économies 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 dont les coûts et les performances sont limités.

Pour en savoir plus, consultez la page Delivering Fast and Light Applications with Save-Data (Fournir des applications rapides et légères avec Save-Data).

Chargement intelligent basé sur les informations réseau

Nous vous conseillons de vérifier navigator.connection.type avant de précharger. Lorsqu'elle est définie sur cellular, vous pouvez empêcher le préchargement et informer 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 Informations sur le réseau pour savoir comment réagir aux changements du réseau.

Mettre en pré-cache plusieurs premiers segments

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

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

Récupération et mise 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'accepte pas encore les réponses Range. Notez que lorsque vous appelez networkResponse.arrayBuffer(), l'intégralité du contenu de la réponse est récupérée 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é-cache 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 démarre immédiatement (si disponible). Sinon, nous allons simplement l'extraire 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 la MSE pour alimenter ce premier segment de la vidéo avec l'élément "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.
      });
    }
  });
}

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

Maintenant, que se passe-t-il si vous avez récupéré un fichier vidéo entier et l'avez enregistré dans l'API Cache ? Lorsque le navigateur envoie une requête HTTP Range, il est préférable de ne pas placer la vidéo entière dans la mémoire du moteur de rendu, car l'API Cache n'accepte pas encore les réponses Range.

Voyons comment intercepter ces requêtes et renvoyer une réponse Range personnalisée 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 handle du fichier tandis que response.arrayBuffer() place 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 officielle, et en particulier son fichier ranged-response.js pour trouver une solution complète pour gérer les requêtes Range.