Auto-hébergement de vidéos avec HLS

Vincent Bernat

Héberger des vidéos sur YouTube est pratique pour plusieurs raisons: bon lecteur, bande passante gratuite, fonctionnement sur mobile, effet réseau et, à la discrétion de l’auteur, pas de publicité1. Par contre, c’est l’une des solutions les moins respectueuses de la vie privée. La plupart des autres fournisseurs partagent les mêmes caractéristiques, à l’exception de la possibilité de désactiver gratuitement les annonces.

Avec la balise <video>, la publication d’une vidéo est simple2 :

<video controls>
  <source src="../videos/big_buck_bunny.webm" type="video/webm">
  <source src="../videos/big_buck_bunny.mp4" type="video/mp4">
</video>

Cependant, bien qu’il soit possible de fournir des vidéos différentes selon la taille de l’écran, adapter la vidéo à la bande passante disponible est plus délicat. Il y a deux solutions :

Il s’agit de deux protocoles de diffusion à débit adaptatif : la vidéo est découpée en petits segments et mise à disposition dans une variété de débits différents. Selon les conditions actuelles du réseau, le lecteur sélectionne automatiquement le débit le plus approprié pour télécharger le segment suivant.

HLS a d’abord été implémenté par Apple mais est maintenant aussi pris en charge nativement par Microsoft Edge et Chrome sur Android. hls.js est une bibliothèque JavaScript apportant le support HLS à d’autres navigateurs. MPEG-DASH est techniquement supérieur (indépendant du codec) mais ne fonctionne qu’ à travers une bibliothèque JavaScript, comme dash.js. Dans les deux cas, le support des Media Source Extensions est nécessaire lorsque le support natif est absent. Safari sur iOS n’a pas cette fonctionnalité et ne peut donc pas utiliser MPEG-DASH. Par conséquent, la solution la plus compatible est actuellement HLS.

Encodage#

Trois types de fichiers sont nécessaires pour diffuser des vidéos HLS :

  • les segments (encodés avec différents débits et résolutions),
  • une liste de lecture des segments pour chacune des variantes,
  • une liste de lecture principale énumérant les listes de lecture pour chaque variante.

Deux formats sont possibles pour les segments :

  • MPEG-2 Transport Streams (TS) ou
  • MP4 fragmenté.

Les segments au format MP4 fragmenté sont supportés depuis iOS 10. Ils sont un peu plus efficaces et peuvent être réutilisés pour servir le même contenu avec MPEG-DASH (seuls les listes de lecture sont différentes). Enfin, les fragments peuvent être servis depuis un même fichier. Cependant, si vous voulez cibler les anciennes versions d’iOS, vous devez vous en tenir au MPEG-2 TS3.

FFmpeg est capable de convertir une vidéo en segments et de générer les listes de lecture associées. La documentation de Peer5 explique les commandes appropriées. J’ai écrit un script (Python 3.6), video2hls, réalisant toutes les étapes. Après l’avoir exécuté sur votre vidéo cible, vous obtenez un répertoire contenant :

  • les segments pour chaque résolution (1080p_1_001.ts, 720p_2_001.ts, …)
  • les listes de lecture pour chaque résolution (1080p_1.m3u8, 720p_2.m3u8, …)
  • la liste de lecture principale (index.m3u8)
  • une version MP4 (progressive.mp4)
  • un poster (poster.jpg)

Le script accepte beaucoup d’options pour modifier son comportement. Le drapeau --help permet de les lister. En le lançant avec --debug, les commandes ffmpeg exécutées sont annotées d’explications. Par exemple, la construction du poster provient de la commande suivante :

ffmpeg \
  `# seek to the given position (5%)` \
   -ss 4 \
  `# load input file` \
   -i ../2018-self-hosted-videos.mp4 \
  `# take only one frame` \
   -frames:v 1 \
  `# filter to select an I-frame and scale` \
   -vf 'select=eq(pict_type\,I),scale=1280:720' \
  `# request a JPEG quality ~ 10` \
   -qscale:v 28 \
  `# output file` \
   poster.jpg

Service#

Nous avons obtenu un tas de fichiers statiques qui peuvent être hébergés n’importe où. Toutefois, deux détails restent importants :

  • Lorsque les fichiers sont servis depuis un autre domaine, il faut configurer CORS pour autoriser les requêtes GET. Ajouter Access-Control-Allow-Origin: * dans les entêtes de réponse est généralement suffisant4.
  • Certains clients peuvent être difficiles sur les types MIME. Assurez-vous que chaque fichier est associé comme indiqué dans le tableau ci-dessous.
Type Extension Type MIME
Liste de lecture .m3u8 application/vnd.apple.mpegurl
Segments MPEG2-TS .ts video/mp2t
Segments fMP4 .mp4 video/mp4
MP4 classique .mp4 video/mp4
Poster .jpg image/jpeg

Hébergons nos fichiers sur le stockage objet d’Exoscale qui est compatible avec S3 et situé en Suisse. À titre d’exemple, la vidéo Caminandes 3: Llamigos pèse environ 213 MiB (cinq tailles pour HLS et un MP4 classique). Cela nous coûterait moins de 0,01 € par mois pour le stockage et 1,42 € pour la bande passante si 1000 personnes regardaient la version 1080p du début à la fin5.

Nous utilisons s3cmd pour envoyer les fichiers. Récupérez d’abord vos identifiants depuis le portail et placez les dans ~/.s3cfg :

[default]
host_base = sos-ch-dk-2.exo.io
host_bucket = %(bucket)s.sos-ch-dk-2.exo.io
access_key = EXO.....
secret_key = ....
use_https = True
bucket_location = ch-dk-2

La seconde étape consiste à créer un bucket :

$ s3cmd mb s3://hls-videos
Bucket 's3://hls-videos/' created

Pour configurer la politique CORS, placez la définition suivante dans un fichier cors.xml (il peut être souhaitable de restreindre les origines autorisées) :

<CORSConfiguration>
 <CORSRule>
   <AllowedOrigin>*</AllowedOrigin>
   <AllowedMethod>GET</AllowedMethod>
 </CORSRule>
</CORSConfiguration>

La commande suivante permet de l’appliquer :

$ s3cmd setcors cors.xml s3://hls-videos

La dernière étape consiste à copier les fichiers statiques. Les listes de lecture sont servies compressées pour économiser un peu de bande passante. Pour chaque vidéo, à l’intérieur du répertoire contenant tous les fichiers générés, utilisez cette commande :

while read extension mime gz; do
  [ -z "$gz" ] || {
    # gzip compression (if not already done)
    for f in *.${extension}; do
      ! gunzip -t $f 2> /dev/null || continue
      gzip $f
      mv $f.gz $f
    done
  }
  s3cmd --no-preserve -F -P \
        ${gz:+--add-header=Content-Encoding:gzip} \
        --mime-type=${mime} \
        --encoding=UTF-8 \
        --exclude=* --include=*.${extension} \
        --delete-removed \
    sync . s3://hls-videos/video1/
done <<EOF
m3u8  application/vnd.apple.mpegurl true
jpg   image/jpeg
mp4   video/mp4
ts    video/mp2t
EOF

Les fichiers sont maintenant disponibles à l’adresse https://hls-videos.sos-ch-dk-2.exo.io/video1/.

HTML#

Le code suivant permet d’insérer la vidéo dans un document HTML :

<video poster="https://hls-videos.sos-ch-dk-2.exo.io/video1/poster.jpg"
       controls preload="none">
  <source src="https://hls-videos.sos-ch-dk-2.exo.io/video1/index.m3u8"
          type="application/vnd.apple.mpegurl">
  <source src="https://hls-videos.sos-ch-dk-2.exo.io/video1/progressive.mp4"
          type='video/mp4; codecs="avc1.4d401f, mp4a.40.2"'>
</video>

Les navigateurs avec support natif utilisent la version HLS alors que les autres utiliseront la version MP4 progressive. Cependant, avec l’aide de hls.js, nous pouvons faire bénéficier la version HLS à la plupart des navigateurs :

<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
    if(Hls.isSupported()) {
        var selector = "video source[type='application/vnd.apple.mpegurl']",
            videoSources = document.querySelectorAll(selector);
        videoSources.forEach(function(videoSource) {
            var m3u8 = videoSource.src,
                once = false;

            // Copier la balise video pour retirer toutes les sources
            var oldVideo = videoSource.parentNode,
                newVideo = oldVideo.cloneNode(false);

            // Remplacer la balise video avec la copie
            oldVideo.parentNode.replaceChild(newVideo, oldVideo);

            // Add an empty source (enable play event on Chromium 72+)
            newVideo.src = "about:blank";

            // Attendre le dernier moment pour initialiser hls.js,
            // une seule fois
            newVideo.addEventListener('play',function() {
                if (once) return;
                once = true;

                var hls = new Hls({ capLevelToPlayerSize: true });
                hls.loadSource(m3u8);
                hls.attachMedia(newVideo);
                hls.on(Hls.Events.MANIFEST_PARSED, function() {
                    newVideo.play();
                });
            }, false);
        });
    }
</script>

Mise à jour (12.2018)

Depuis Chromium 72, une vidéo sans source (comme construite par le code ci-dessus) ne déclenche plus l’évènement play. Aussi, nous ajoutons about:blank comme source.

Voici le résultat avec Caminandes 3: Llamigos, une vidéo créée par Pablo Vasquez, produite par la fondation Blender et publiée sous licence Creative Commons Attribution 3.0 :

La plupart des attributs, méthodes et évènements JavaScript fonctionnent de la même façon qu’avec un simple élément <video>. Par exemple, vous pouvez atteindre une position arbitraire, comme 1:00 ou 2:00 (mais vous avez besoin d’activer JavaScript à cet effet).

Le lecteur est différent d’un navigateur à l’autre mais fournit généralement les fonctions basiques. Vous pouvez évoluer vers un lecteur plus avancé, comme video.js out MediaElements.js. Ils gèrent également HLS via hls.js.

Mise à jour

Pour les sous-titres, jetez un œil à « Auto-hébergement de vidéos avec HLS : sous-titres ».

Héberger ses vidéos sur YouTube n’est pas une fatalité : les servir soi-même tout en proposant une livraison de qualité est techniquement accessible. Si les besoins en bande passante sont modestes et l’effet réseau peu important, l’auto-hébergement permet de reprendre le contrôle des contenus publiés et de ne pas livrer ses lecteurs à Google. Dans le même esprit, PeerTube offre une plateforme de partage des vidéos. Décentralisée et fédérée, elle repose sur BitTorrent pour réduire les besoins en bande passante.

Annexe#

Préchargement#

Dans l’exemple ci-dessus, preload="none" est utilisé pour deux raisons :

  • La plupart des lecteurs ne vont pas visionner la vidéo qui ne sert que d’illustration au contenu principal. Par conséquent, la bande passante n’est pas gaspillée en téléchargeant quelques segments de vidéo, au détriment d’une latence légèrement accrue en début de lecture.
  • Nous ne voulons pas que les clients ne comprenant pas HLS nativement commencent à télécharger la version non-HLS alors que hls.js s’initialise. Cela pourrait aussi être fait en ajoutant le MP4 progressif à partir de JavaScript, mais cela rendrait la vidéo impossible à lire pour les utilisateurs sans JavaScript. Si le préchargement est important, vous pouvez supprimer l’attribut preload via JavaScript (et ne pas attendre une demande de lecture pour initialiser hls.js).

CSP#

Configurer correctement CSP est plutôt pénible. Pour les navigateurs avec support HLS natif, il suffit d’ajouter la politique suivante en plus de la politique existante :

  • image-src https://hls-videos.sos-ch-dk-2.exo.io pour les posters,
  • media-src https://hls-videos.sos-ch-dk-2.exo.io pour les listes de lecture et les segments.

Avec hls.js, les choses se compliquent. Idéalement, il faut ajouter les éléments suivants :

  • worker-src blob: pour la conversion du format vidéo en tâche de fond,
  • media-src blob: about: pour la lecture des segments convertis,
  • connect-src https://hls-videos.sos-ch-dk-2.exo.io pour récupérer les listes de lecture et les segments depuis JavaScript.

Toutefois, worker-src est un ajout récent. Les navigateurs doivent se replier sur child-src (obsolète), script-src (mais pas partout) puis default-src. Ainsi, pour une meilleure compatibilité, il faut ajouter blob: à default-src ainsi qu’à script-src et child-src s’ils sont déjà présents. Voici un exemple pour lequel la politique initiale est default-src 'self' :

HTTP/1.0 200 OK
Content-Security-Policy:
  default-src 'self' blob:;
  image-src 'self' https://hls-videos.sos-ch-dk-2.exo.io;
  media-src blob: about: https://hls-videos.sos-ch-dk-2.exo.io;
  connect-src https://hls-videos.sos-ch-dk-2.exo.io;
  worker-src blob:;

  1. Désormais, YouTube impose de la publicité sur toutes les vidéos↩︎

  2. De nos jours, tous les navigateurs supportent MP4/H.264. De plus, cela permet généralement de profiter de l’accélération matérielle, ce qui améliore la durée de vie de la batterie sur les téléphones portables. WebM/VP9 permet d’obtenir une meilleure qualité à débit identique. ↩︎

  3. Vous pouvez générer les deux formats et les utiliser comme variantes dans la liste de lecture principale. Cependant, un bug dans hls.js interdit cette option. ↩︎

  4. L’astérisque peut être remplacée par https://example.org pour restreindre l’accès à son propre domaine. ↩︎

  5. Nul besoin de placer les fichiers derrière un CDN. La latence importe peu à condition d’avoir un débit suffisant. ↩︎