Convertir des vidéos en HLS et MPEG-DASH avec Ruby et FFmpeg
Empaquetez une vidéo locale pour la lecture à la demande avec Ruby et FFmpeg. Cet exemple produit trois rendus HLS, une playlist maîtresse indiquant la bande passante mesurée des segments, et un manifeste DASH avec deux représentations vidéo. Vous servirez les fichiers terminés en HTTP sur l’interface de bouclage (loopback) et lirez les deux formats dans un navigateur.
Introduction à HLS et MPEG-DASH
HLS utilise des playlists .m3u8 ; MPEG-DASH utilise un manifeste .mpd. Chaque index pointe vers des
segments média. Plusieurs rendus compatibles permettent à un lecteur de choisir une qualité, mais
l’empaquetage seul ne prouve ni une adaptation fluide ni une réduction de la mise en mémoire tampon
sur un réseau réel. L’échelle de débits présentée ici est un exemple, et non une recommandation
valable pour toutes les vidéos.
Configurer votre environnement Ruby pour la conversion vidéo
Utilisez Bash sous Linux, avec Ruby, RubyGems, FFmpeg, ffprobe et Python accessibles dans PATH.
L’environnement testé utilise Ruby 3.4.10, FFmpeg/ffprobe 9.0.1 et Python 3.14.7. Choisissez une
version de Ruby maintenue ; Ruby 3.2 a atteint sa fin de vie.
Votre build de FFmpeg doit inclure les encodeurs libx264 et aac ainsi que les muxers HLS et DASH :
ruby -v && gem -v && python3 --version &&
ffmpeg -version && ffprobe -version && ffmpeg -encoders && ffmpeg -muxers
Collez le bloc suivant dans Bash depuis un répertoire accessible en écriture. Il crée un nouveau
projet et y installe les gems à l’aide des
options de répertoire d’installation explicites de RubyGems.
Un répertoire video_conversion existant interrompt la configuration. En cas d’échec de l’installation,
vous revenez au répertoire d’origine ; inspectez le nouveau répertoire avant de réessayer plutôt
que de supprimer un travail existant.
(
unset RUBYOPT RUBYLIB BUNDLE_GEMFILE
mkdir video_conversion &&
cd video_conversion &&
export GEM_HOME="$PWD/.gems" GEM_PATH="$PWD/.gems" GEM_SPEC_CACHE="$PWD/.gem-specs" &&
gem install streamio-ffmpeg -v 3.0.2 --norc --no-document --no-user-install --install-dir "$GEM_HOME" &&
gem install logger -v 1.7.0 --norc --no-document --no-user-install --install-dir "$GEM_HOME"
) && cd video_conversion
Cette configuration autonome efface les paramètres hérités du chargeur Ruby dans le sous-shell,
afin qu’un projet Bundler englobant ne sélectionne pas ses gems. La commande d’exécution ci-dessous
utilise la même isolation.
Copiez dans le projet un fichier local dont vous savez qu’il fonctionne, sous le nom input.mp4 :
utilisez une vidéo H.264 SDR à pixels carrés, une image au format 16:9 d’au moins 1280×720 et une
piste audio. Les images ayant un autre rapport largeur/hauteur seraient étirées par cette échelle
fixe. Choisissez un extrait de plus de 20 secondes pour couvrir plusieurs limites de segments.
Les vidéos sans piste audio nécessitent une logique de mappage et de playlist différente.
Convertir des vidéos au format HLS en Ruby
La gem Streamio FFMPEG fournit l’inspection de l’entrée et les options d’encodeur. Sa dernière version publiée est la 3.0.2, sortie en 2016, et ses notes de compatibilité en amont visent des versions bien plus anciennes de FFmpeg. Cet exemple teste son usage limité avec les versions ci-dessus et appelle FFmpeg directement avec un tableau d’arguments, en vérifiant le code de sortie. Chaque conversion crée un nouveau répertoire de sortie ; un répertoire existant est refusé afin de protéger les playlists et segments antérieurs.
Créez un nouveau fichier nommé video_converter.rb :
require 'streamio-ffmpeg'
require 'fileutils'
class VideoConverter
def initialize(input_file)
begin
@movie = FFMPEG::Movie.new(input_file)
raise "Invalid file" unless @movie.valid?
@input_file = input_file
rescue FFMPEG::Error => e
raise "FFmpeg error: #{e.message}"
rescue StandardError => e
raise "Error initializing converter: #{e.message}"
end
end
def to_hls(output_dir)
FileUtils.mkdir_p(File.dirname(output_dir))
Dir.mkdir(output_dir)
variants = [
{ resolution: '1280x720', video_bitrate: '2500k', audio_bitrate: '128k' },
{ resolution: '854x480', video_bitrate: '1500k', audio_bitrate: '96k' },
{ resolution: '640x360', video_bitrate: '800k', audio_bitrate: '64k' }
]
begin
variants.each do |variant|
options = {
video_codec: 'libx264',
audio_codec: 'aac',
frame_rate: 30,
resolution: variant[:resolution],
video_bitrate: variant[:video_bitrate],
audio_bitrate: variant[:audio_bitrate],
custom: [
'-map', '0:v:0', '-map', '0:a:0',
'-pix_fmt', 'yuv420p', '-threads', '2',
'-g', '300', '-keyint_min', '300', '-sc_threshold', '0',
'-force_key_frames', 'expr:gte(t,n_forced*10)',
'-hls_time', '10',
'-hls_playlist_type', 'vod',
'-hls_segment_filename', "#{output_dir}/#{variant[:resolution]}_%03d.ts"
]
}
transcode("#{output_dir}/#{variant[:resolution]}.m3u8", options)
end
generate_master_playlist(output_dir, variants)
rescue FFMPEG::Error => e
raise "Transcoding error: #{e.message}"
rescue StandardError => e
raise "General error: #{e.message}"
end
end
def to_dash(output_dir)
FileUtils.mkdir_p(File.dirname(output_dir))
Dir.mkdir(output_dir)
begin
options = {
video_codec: 'libx264',
audio_codec: 'aac',
frame_rate: 30,
custom: [
'-map', '0:v:0', '-map', '0:v:0', '-map', '0:a:0',
'-pix_fmt', 'yuv420p', '-threads', '2',
'-s:v:0', '1280x720', '-b:v:0', '2500k',
'-s:v:1', '640x360', '-b:v:1', '800k',
'-g', '300', '-keyint_min', '300', '-sc_threshold', '0',
'-force_key_frames', 'expr:gte(t,n_forced*10)',
'-use_template', '1',
'-use_timeline', '1',
'-seg_duration', '10',
'-adaptation_sets', 'id=0,streams=v id=1,streams=a',
'-f', 'dash'
]
}
transcode("#{output_dir}/manifest.mpd", options)
rescue FFMPEG::Error => e
raise "DASH conversion error: #{e.message}"
rescue StandardError => e
raise "General error: #{e.message}"
end
end
private
def transcode(output_file, options)
arguments = FFMPEG::EncodingOptions.new(options).to_a
system(FFMPEG.ffmpeg_binary, '-nostdin', '-n', '-i', @input_file,
*arguments, output_file, exception: true)
end
def generate_master_playlist(output_dir, variants)
master_playlist = "#EXTM3U\n#EXT-X-VERSION:3\n"
variants.each do |variant|
# Measure the multiplexed segments, including audio and container overhead.
duration = nil
target_duration = nil
segments = []
File.foreach("#{output_dir}/#{variant[:resolution]}.m3u8") do |line|
if line.start_with?('#EXT-X-TARGETDURATION:')
target_duration = Integer(line.split(':', 2).last)
elsif line.start_with?('#EXTINF:')
duration = Float(line.delete_prefix('#EXTINF:').split(',').first)
elsif !line.start_with?('#') && !line.strip.empty?
raise 'Missing segment duration' unless duration && duration.positive?
segments << { bytes: File.size(File.join(output_dir, line.strip)), duration: duration }
duration = nil
end
end
raise 'Missing HLS target duration' unless target_duration && target_duration.positive?
rates = []
segments.each_index do |first|
bytes = 0
seconds = 0.0
(first...segments.length).each do |last|
bytes += segments[last][:bytes]
seconds += segments[last][:duration]
break if seconds > 1.5 * target_duration
rates << bytes * 8 / seconds if seconds >= 0.5 * target_duration
end
end
raise 'No measurable HLS segment window' if rates.empty?
bandwidth = rates.max.ceil
master_playlist += <<~PLAYLIST
#EXT-X-STREAM-INF:BANDWIDTH=#{bandwidth},RESOLUTION=#{variant[:resolution]}
#{variant[:resolution]}.m3u8
PLAYLIST
end
File.write("#{output_dir}/master.m3u8", master_playlist)
end
end
Une fois les trois playlists média terminées, Ruby écrit master.m3u8. Pour chaque rendu, il mesure
les fenêtres de segments contigus d’une durée comprise entre 0,5 et 1,5 fois la durée cible de la
playlist, puis arrondit au supérieur le débit le plus élevé pour BANDWIDTH, conformément à la
définition HLS du débit binaire de crête par segment.
Cela inclut l’audio et la surcharge MPEG-TS. Un segment final court est mesuré avec son voisin
lorsqu’il est trop court pour être retenu seul ; utiliser le débit de l’encodeur vidéo omettrait une
partie des données livrées.
Convertir des vidéos en MPEG-DASH en Ruby
La méthode to_dash ci-dessus mappe deux fois la vidéo d’entrée, ce qui crée des représentations
720p et 360p, plus une représentation audio partagée. Les deux encodages vidéo utilisent la même
fréquence d’images et forcent des images clés toutes les dix secondes, de sorte que les limites de
segments s’alignent. HLS utilise le même calendrier d’images clés pour ses trois rendus. Le dernier
segment peut durer moins de dix secondes. La documentation des muxers
HLS et
DASH de FFmpeg explique les options d’empaquetage.
Utiliser le convertisseur
Enregistrez ce second fichier sous le nom convert.rb, à côté de video_converter.rb et input.mp4 :
require_relative 'video_converter'
begin
converter = VideoConverter.new('input.mp4')
puts 'Converting to HLS…'
converter.to_hls('output/hls')
puts 'Converting to MPEG-DASH…'
converter.to_dash('output/dash')
puts 'Conversion completed successfully'
rescue StandardError => e
warn "Error: #{e.message}"
exit 1
end
Exécutez depuis video_conversion :
env -u RUBYOPT -u RUBYLIB -u BUNDLE_GEMFILE \
GEM_HOME="$PWD/.gems" GEM_PATH="$PWD/.gems" ruby convert.rb
Attendez le message Conversion completed successfully. Le résultat comprend
output/hls/master.m3u8, ses trois playlists média et ses segments .ts, ainsi que
output/dash/manifest.mpd, les fichiers d’initialisation et les segments .m4s. Conservez chaque index avec
les fichiers qu’il référence. Une nouvelle exécution du script refuse le répertoire output/hls existant.
Tester les flux
Enregistrez cette page sous le nom output/player.html. Chaque lecteur n’a qu’une seule source, de sorte que
le test de HLS ne peut pas remplacer silencieusement le test de DASH.
Video.js HTTP Streaming est inclus dans la build standard de
Video.js et prend en charge les deux formats grâce à Media Source Extensions.
La bibliothèque et la feuille de style sont chargées depuis un CDN ; cette page nécessite donc un
accès à Internet.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Local HLS and DASH playback</title>
<link href="https://cdn.jsdelivr.net/npm/video.js@8.24.0/dist/video-js.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/video.js@8.24.0/dist/video.min.js"></script>
<style>html { color-scheme: light dark; }</style>
</head>
<body>
<section aria-label="HLS">
<h1>HLS</h1>
<video id="hls" class="video-js" controls preload="metadata" width="640" height="360">
<source src="hls/master.m3u8" type="application/x-mpegURL" />
</video>
</section>
<section aria-label="MPEG-DASH">
<h2>MPEG-DASH</h2>
<video id="dash" class="video-js" controls preload="metadata" width="640" height="360">
<source src="dash/manifest.mpd" type="application/dash+xml" />
</video>
</section>
<script>
videojs('hls', { fluid: true })
videojs('dash', { fluid: true })
</script>
</body>
</html>
Depuis video_conversion, démarrez le serveur HTTP local de Python au premier plan :
python3 -m http.server 8080 --bind 127.0.0.1 --directory output
Ouvrez http://127.0.0.1:8080/player.html dans un navigateur prenant en charge H.264/AAC et Media Source Extensions.
Lisez chaque vidéo jusqu’à la fin, puis déplacez la tête de lecture au-delà d’une limite de segment.
Cette page a été vérifiée avec Video.js 8.24.0 et Chromium 145 sous Linux. Arrêtez le serveur avec
Ctrl+C. Si le port 8080 est occupé, choisissez un autre port dans la commande et utilisez-le dans
l’URL. Le serveur HTTP de Python ne sert ici que
le répertoire output ; limitez cette vérification locale à des médias de test publics.
Vérifier les échecs et les médias finaux
Une entrée impossible à ouvrir ou à reconnaître, une piste audio manquante ou un processus FFmpeg en
échec fait que convert.rb se termine avec un code de sortie non nul. Les diagnostics de FFmpeg
nomment l’entrée ou la sortie concernée. Une exécution échouée peut laisser des fichiers partiels
dans son nouveau répertoire, et une réussite HLS suivie d’un échec DASH laisse les fichiers HLS
terminés. Ne publiez qu’une fois le script entier exécuté avec succès.
Pour réessayer, utilisez de nouveaux noms de répertoires de sortie dans convert.rb et mettez à jour les
chemins source du lecteur en conséquence.
FFmpeg peut récupérer après une entrée endommagée et renvoyer quand même zéro. Une conversion réussie n’est pas une vérification d’intégrité : comparez l’image, l’audio et la durée du résultat final avec votre source. Inspectez les trois rendus HLS et les deux représentations vidéo DASH, pas seulement la qualité sélectionnée par le lecteur. Vérifiez que chaque référence de playlist ou de manifeste est résolue et que la lecture atteint la fin attendue. Un nom de fichier de manifeste ou une première image réussie ne garantit pas une lecture complète.
Aller au-delà de l’exemple local
La méthode HLS encode l’entrée trois fois de suite ; DASH crée les deux représentations vidéo dans un seul processus FFmpeg. Commencez avec une seule tâche de conversion et mesurez l’utilisation du processeur, de la mémoire et du disque avant d’ajouter des workers. Ces appels de sous-processus vérifiés ne fournissent ni délai d’expiration ni frontière d’isolation pour les fichiers envoyés non fiables. Un service de diffusion en production a aussi besoin de sa propre configuration de publication et HTTP ; ce tutoriel vérifie l’empaquetage et la lecture locale.
Conclusion
Conservez ensemble les manifestes terminés et leurs segments lorsque vous déplacez le résultat vers votre configuration de publication. Pour un encodage géré, découvrez le service d’encodage vidéo de Transloadit.
