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 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 décrire trois méthodes de préchargement des 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 des ressources commence lorsque 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 ce type d'élément à MSE.
Préchargement du lien Force le navigateur à demander une ressource vidéo sans bloquer l'événement onload du document. Les requêtes HTTP de plage 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 complexe des erreurs 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 contenu à 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. (Elle était auto précédemment.)

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

Il y a cependant 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 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 à 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, nous vous recommandons 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 des pages si les vidéos ne font pas partie de l'expérience utilisateur principale.

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 du contenu vidéo, il est lu dans le 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 comment 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 l'intégralité de la vidéo a été divisée en plusieurs 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 ce 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.

Détection 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 de charge 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 "Data-Saver"

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 dont les coûts et les performances sont limités.

Pour en savoir plus, consultez la page Proposer 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 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.

Mettre en pré-cache plusieurs premiers segments

Que se passe-t-il si je souhaite précharger de manière spéculative un contenu multimédia sans savoir lequel 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 mettre en pré-cache plusieurs premiers segments de vidéo à l'aide de l'API Cache, puissante et facile à utiliser. Notez que vous pouvez également obtenir des résultats similaires avec IndexedDB. Nous n'utilisons pas encore de service workers, car l'API Cache est également accessible depuis l'objet window.

Extraire 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 démarre immédiatement si elle est disponible. 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 Range avec un service worker

Que se passe-t-il si vous avez récupéré un fichier vidéo complet et l'avez enregistré dans l'API Cache ? Lorsque le navigateur envoie une requête HTTP Range, vous ne souhaitez certainement pas mettre 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.

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 handle vers le fichier, tandis que response.arrayBuffer() apporte 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, et en particulier son fichier ranged-response.js, pour découvrir une solution complète de gestion des requêtes Range.