HTML5 <video> like a boss

J’avais commencé cet article en juillet 2015 mais il était un peu tombé dans les oubliettes (trop de travail, tout ça).

J’avais dans l’idée de présenter et d’expérimenter la manipulation de flux audio et vidéo en HTML5 mais je me suis limité devant la quantité d’informations et la complexité à l’expliquer simplement et succinctement. En espérant que cet article vous soit tout de même utile :-).

Note : Le contenu de cet article est tout à fait applicable à <audio>.

Ajouter une vidéo à une page

Je ne vais pas m’attarder sur ce point, HTML5 permet maintenant d’ajouter facilement une vidéo à une page. Les premières difficultés seront liées aux formats de fichier à utiliser et aux fallbacks à mettre en place (non couvert dans cet article).

<video src="bip-bop.mp4" controls></video>

Code HTML permettant d’ajouter une vidéo, accompagné de son rendu

Cette vidéo pèse plus de 50 Mo ce qui, pour 30 minutes, est correct. Toutefois, cela soulève un problème de bande passante.

Dès le lancement de la lecture, le navigateur va commencer à la télécharger en entier, même si l’utilisateur met le lecteur en pause. Dans mon exemple, je doute que quiconque regarde la vidéo en entier ; vous allez donc gaspiller plus de 50 Mo (problématique si votre fournisseur d’accès vous impose un quota). Le serveur sera également occupé à transférer ce fichier et aura moins de capacité de charge.

Progressive download et streaming

Le progressive download est la technique de chargement de média utilisée par les navigateurs. Dans le cas d’une vidéo, cela permet de commencer la lecture avant la fin du chargement du fichier.

Le chargement est linéaire et la totalité du fichier sera récupéré. Si vous souhaitez accéder à une partie non chargée, votre navigateur va attendre avant de reprendre la lecture (en réalité les navigateurs gèrent mieux ce cas, nous le verrons plus tard).

Illustration du chargement et de la lecture en progressive download
(supposant que la connexion soit capable de charger plus vite que la lecture)

Le streaming est une autre technique de chargement de média. Cette fois, le fichier sera découpé en segments qui seront téléchargé à la demande. Un fichier manifeste, listant les segments, sera à utiliser comme source pour le lecteur.

Illustration du chargement et de la lecture en streaming

La consommation de données est réduite côté client comme côté serveur. En bénéfice, le lecteur peut commencer la lecture sur n’importe quel segment, sans avoir à en télécharger les précédents.

Malheureusement, HTML5 ne permet pas « nativement » d’utiliser le streaming. Il est toutefois possible de faire du pseudo-streaming permettant au moins de démarrer la lecture à un endroit donné, sans attendre le chargement préalable. Les navigateurs actuels envoient un header HTTP Range indiquant au serveur quelle portion est à récupérer. Ce dernier doit être capable d’extraire une partie de la vidéo, cela peut nécessiter de la configuration et/ou l’utilisation d’un module.

Adaptive streaming

L’adaptive streaming (et non « adaptative ») est une technique permettant d’adapter (ça alors !) la qualité d’une vidéo en fonction de l’utilisation de la bande passante du client voire même de l’utilisation CPU.

Concrètement, cela veut dire que la lecture peut commencer sur des segments en basse qualité puis passer en haute qualité, si le client en est capable. Le manifeste utilisé comporte une déclinaison des segments à utiliser.

Simulation d’adaptive streaming
Toutes les 4 secondes la qualité est améliorée

Avec cette technique, le démarrage de la lecture peut être plus rapide, mais surtout cela évite d’avoir à envoyer une qualité trop élevée aux clients n’en ayant pas la capacité.

Par contre, l’inconvénient majeur est qu’il faut préparer toutes les qualités disponibles. Si vous proposez 5 qualités, il va falloir encoder (compresser) 5 fois la vidéo et la stocker en 5 exemplaires.

Vous l’aurez compris, idéalement un lecteur HTML5 devrait avoir la capacité de télécharger des segments et avoir un algorithme de décision de qualité.

Media Source Extensions

Media Source Extensions (MSE) est une spécification décrivant un ensemble d’interfaces JavaScript permettant de créer une source lisible avec une balise <video> ou <audio>.

Cela permet d’agir directement sur les flux, comme par exemple changer de piste audio, charger uniquement les segments nécessaires (streaming), adapter la qualité (adaptive streaming), valider des segments (DRM) ou encore faire du gapless playback.

Essayons de nous en servir :

var
  video  = document.getElementById("video"),
  source = new MediaSource();

video.src = URL.createObjectURL(source);

Pour le moment rien de compliqué, nous créons une source que nous attachons à une balise <video> présente dans notre page. Vous noterez que cette source est une Blob URL (une URL vers un objet binaire).

Il faut maintenant remplir cette source :

source.addEventListener("sourceopen", function() {
  var
    buffer = source.addSourceBuffer("video/mp4"),
    xhr    = new XMLHttpRequest();

  xhr.open("GET", "2016/01/27/html5-video-like-a-boss/bip-bop.dash.mp4", true);
  xhr.responseType = "arraybuffer";
  xhr.setRequestHeader("Range", "bytes=0-307200");
  xhr.send();

  xhr.onload = function() {
    buffer.appendBuffer(xhr.response);
  }
}, false);

L’événement sourceopen est déclenché par le navigateur lorsqu’il juge nécessaire de commencer à charger la source (en fonction de l’attribut preload, du lancement de la lecture voire d’autres optimisations internes au navigateur). Nous créons un buffer dans lequel nous injectons les données brutes de la vidéo.

Pour cet exemple, nous limitons les données à 300 ko en ajoutant un header Range à la requête HTTP. Notez également que nous définissons le type de réponse à arraybuffer afin de manipuler correctement la réponse.

Rendu du code précédent
Seul 300 ko du fichier sont chargés (environ 10 secondes)
(Le rendu peut ne pas fonctionner suivant votre navigateur)

J’ai volontairement simplifié cet exemple, en réalité il faut tenir compte de certains points :

  • Au moment d’ajouter un buffer (source.addSourceBuffer()) il faut spécifier le type MIME des données qu’il va recevoir (probablement pour que l’algorithme de sélection de source fonctionne). Certains navigateurs auront également besoin de l’information concernant les codecs ("video/webm; codecs=vp9,vorbis" ou "video/mp4; codecs=avc1.4d4028,mp4a.40.2" par exemple).
  • Après avoir ajouté des données au buffer il sera verrouillé pendant quelque temps (le temps que le codec valide ou invalide les données). Pour continuer à l’alimenter correctement, il vaut mieux lancer la requête pour le segment suivant après avoir reçu l’événement updateend (déclenché par le buffer).
  • Vous aurez sans doute besoin de préparer vos fichiers MP4 pour un chargement en streaming.
  • Les 300 ko sont arbitraires. Dans le cas d’un fichier MP4 cela ne sera peut-être pas suffisant pour commencer la lecture (le début du fichier contenant des métadonnées pouvant être imposantes). L’idéal serait d’utiliser un manifeste DASH décrivant les intervalles des segments.

Modifions cet exemple pour charger les autres segments :

var
  video  = document.getElementById("video"),
  source = new MediaSource(),
  buffer,
  ranges          = ["0-291281", …, "53143120-53444342"],
  rangesRetrieved = [],
  segmentDuration = 10.021;

video.src = URL.createObjectURL(source);

Tout d’abord, nous devons lister les headers Range nous permettant de récupérer les segments. Nous allons également avoir besoin de conserver une liste des segments en cours de récupération et de la durée d’un segment.

Nous allons avoir besoin de récupérer plusieurs segments, il est donc nécessaire d’externaliser la requête :

source.addEventListener("sourceopen", function() {
  buffer = source.addSourceBuffer("video/mp4");
  retrieve(0); // Récupérons le premier segment
}, false);

function retrieve(range) {
  // Il faut ignorer les segments en cours de récupération ou déjà récupéré
  if (rangesRetrieved.indexOf(range) !== -1) { return; }

  rangesRetrieved.push(range); // Marquons ce segment comme "récupéré"
  range = ranges[range]; // Retrouvons les bytes à utiliser pour la requête

  var xhr = new XMLHttpRequest();
  xhr.open("GET", "2016/01/27/html5-video-like-a-boss/bip-bop.dash.mp4", true);
  xhr.setRequestHeader("Range", "bytes=" + range);
  xhr.responseType = "arraybuffer";
  xhr.send();

  xhr.onload = function() {
    buffer.appendBuffer(xhr.response);
  }
}

Il nous suffit d’écouter l’événement timeupdate et de charger le prochain segment, si nécessaire :

video.addEventListener("timeupdate", function() {
  var
    index,
    i     = 0,
    count = this.buffered.length,
    time  = this.currentTime + segmentDuration;

  // Vérifions s’il est nécessaire de récupérer le prochain segment
  for (; i < count; i++) {
    if (time > this.buffered.start(i) && time < this.buffered.end(i)) {
      return; // Le prochain segment semble déjà chargé, inutile de continuer
    }
  }

  index = Math.floor(time / segmentDuration);
  retrieve(index);
}, false);

Enfin, en écoutant l’événement seeking nous pouvons lancer le chargement du segment pour une position demandée :

video.addEventListener("seeking", function() {
  index = Math.floor(this.currentTime / segmentDuration);
  retrieve(index);
}, false);

Rendu du code précédent
Vous pouvez consulter le code complet dans le code source de cette page
(Le rendu peut ne pas fonctionner suivant votre navigateur)

Bien entendu, le résultat n’est pas parfait. Idéalement, les headers Range et la durée des segments seraient à récupérer dans un manifeste DASH. Il est également possible d’opter pour une autre méthode de récupération des segments, comme avoir un fichier par segment par exemple.

Ce petit code est facilement modifiable afin de réaliser de l’adaptive streaming. Il suffit d’ajouter un algorithme changeant l’URL vers les segments.

C’est d’ailleurs là un grand intérêt de MSE : donner la possibilité aux développeurs d’implémenter les algorithmes qui conviennent le mieux au contexte de consommation du contenu.

Conclusion

Il y aurait encore énormément à présenter, comme EME qui permet de décrypter les segments et donc de proposer une protection des flux ou encore énumérer des algorithmes adaptatifs. Un jour, peut-être.

Commentaires

Les commentaires pour ce billet sont fermés pour une raison technique. Vous pouvez me contacter par (mais je mets du temps à répondre).

À propos du billet

lundi 06 juillet 2015 à 12:00

Classé dans :

aucun commentaire

Navigation inter-billets