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 :
- HLS (RFC 8216, non normative) et
- MPEG-DASH (ISO/IEC 23009-1:2014).
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
. AjouterAccess-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:;
-
Désormais, YouTube impose de la publicité sur toutes les vidéos. ↩︎
-
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. ↩︎
-
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. ↩︎
-
L’astérisque peut être remplacée par
https://example.org
pour restreindre l’accès à son propre domaine. ↩︎ -
Nul besoin de placer les fichiers derrière un CDN. La latence importe peu à condition d’avoir un débit suffisant. ↩︎