Convierte videos a HLS y MPEG-DASH con Ruby y FFmpeg
Empaqueta un video local para reproducirlo bajo demanda con Ruby y FFmpeg. Este ejemplo produce tres variantes HLS, una lista de reproducción maestra con el ancho de banda medido de los segmentos y un manifiesto DASH con dos representaciones de video. Servirás los archivos terminados por HTTP a través de la interfaz de bucle local y reproducirás ambos formatos en un navegador.
Introducción a HLS y MPEG-DASH
HLS usa listas de reproducción .m3u8; MPEG-DASH usa un manifiesto .mpd.
Cada índice apunta a segmentos multimedia. Varias variantes compatibles permiten que un reproductor
elija una calidad, pero el empaquetado por sí solo no demuestra una adaptación fluida ni una reducción
del almacenamiento en búfer en una red real. La escala de tasas de bits de este ejemplo no es una
recomendación para todos los videos.
Configura tu entorno Ruby para la conversión de video
Usa Bash en Linux con Ruby, RubyGems, FFmpeg, ffprobe y Python en PATH.
El entorno probado usa Ruby 3.4.10, FFmpeg/ffprobe 9.0.1 y Python 3.14.7. Elige una
versión de Ruby con mantenimiento; Ruby 3.2 ha llegado al final de su vida útil.
Tu compilación de FFmpeg necesita los codificadores libx264 y aac,
así como los multiplexores HLS y DASH:
ruby -v && gem -v && python3 --version &&
ffmpeg -version && ffprobe -version && ffmpeg -encoders && ffmpeg -muxers
Pega el siguiente bloque en Bash desde un directorio con permisos de escritura. Crea un proyecto
nuevo e instala las gemas dentro de él mediante las
opciones explícitas del directorio de instalación de RubyGems.
Si ya existe un directorio video_conversion, la configuración se detiene. Si la instalación
falla, vuelves al directorio original; inspecciona el nuevo directorio antes de reintentar, en lugar
de eliminar el trabajo existente.
(
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
Esta configuración independiente borra los ajustes heredados del cargador de Ruby dentro del subshell,
para que un proyecto Bundler que lo contenga no seleccione sus gemas. El comando de ejecución que
aparece más adelante usa el mismo aislamiento.
Copia un archivo local que sepas que funciona correctamente en el proyecto como input.mp4:
usa video H.264 SDR con píxeles cuadrados, una imagen 16:9 de al menos 1280×720 y una pista de audio.
Esta escala fija estiraría otras relaciones de aspecto. Elige un clip de más de 20 segundos para
probar varios límites de segmento. Los videos sin audio necesitan otra lógica de mapeo y de listas
de reproducción.
Convierte videos al formato HLS en Ruby
La gema Streamio FFMPEG proporciona inspección de la entrada y opciones del codificador. Su última versión publicada es la 3.0.2, lanzada en 2016, y las notas de compatibilidad del proyecto original se refieren a versiones de FFmpeg mucho más antiguas. Este ejemplo prueba su uso limitado con las versiones indicadas e invoca FFmpeg directamente con un array de argumentos y la comprobación del estado de salida. Cada conversión crea un directorio de salida nuevo; se rechaza un directorio existente para proteger las listas de reproducción y los segmentos anteriores.
Crea un archivo nuevo llamado 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
Una vez terminadas las tres listas de reproducción multimedia, Ruby escribe master.m3u8.
Para cada variante, mide ventanas de segmentos contiguos que duran entre 0,5 y 1,5 veces la duración
objetivo de la lista de reproducción y redondea hacia arriba la tasa más alta para
BANDWIDTH, siguiendo la
definición de HLS de la tasa de bits máxima de los segmentos.
Esto incluye el audio y los datos adicionales de MPEG-TS. Un segmento final corto se mide junto con
su vecino cuando es demasiado corto para cumplir el criterio por sí solo; usar la tasa de bits del
codificador de video omitiría parte de los datos entregados.
Convierte videos a MPEG-DASH en Ruby
El método to_dash anterior mapea el video de entrada dos veces y crea
representaciones de 720p y 360p, además de una representación de audio compartida. Ambas codificaciones
de video usan la misma tasa de fotogramas y fuerzan fotogramas clave cada diez segundos, de modo que
los límites de los segmentos coincidan. HLS usa la misma distribución de fotogramas clave para sus
tres variantes. El segmento final puede durar menos de diez segundos. La documentación de los
multiplexores HLS y
DASH de FFmpeg explica las opciones de empaquetado.
Usa el conversor
Guarda este segundo archivo como convert.rb, junto a video_converter.rb y 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
Ejecuta lo siguiente desde video_conversion:
env -u RUBYOPT -u RUBYLIB -u BUNDLE_GEMFILE \
GEM_HOME="$PWD/.gems" GEM_PATH="$PWD/.gems" ruby convert.rb
Espera a que aparezca Conversion completed successfully. El resultado es
output/hls/master.m3u8, sus tres listas de reproducción multimedia y los segmentos
.ts, además de output/dash/manifest.mpd, los archivos de inicialización
y los segmentos .m4s. Conserva cada índice junto con los archivos a los que
hace referencia. Si vuelves a ejecutar el script, se rechazará el directorio existente output/hls.
Prueba las transmisiones
Guarda esta página como output/player.html. Cada reproductor tiene una sola fuente, por lo
que probar HLS no puede sustituir inadvertidamente la prueba de DASH.
Video.js HTTP Streaming se incluye en la compilación estándar de
Video.js y admite ambos formatos mediante Media Source Extensions. La biblioteca y la hoja de
estilos se cargan desde una CDN, por lo que esta página necesita acceso a 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>
Desde video_conversion, inicia el servidor HTTP local de Python en primer plano:
python3 -m http.server 8080 --bind 127.0.0.1 --directory output
Abre http://127.0.0.1:8080/player.html en un navegador compatible con H.264/AAC y Media Source Extensions.
Reproduce cada video hasta el final y luego salta a un punto al otro lado de un límite de segmento.
Esta página se comprobó con Video.js 8.24.0 y Chromium 145 en Linux. Detén el servidor con Ctrl+C.
Si el puerto 8080 está ocupado, elige otro puerto en el comando y úsalo en la URL. El
servidor HTTP de Python sirve aquí solo el directorio
output; limita esta comprobación local a contenido multimedia de prueba público.
Revisa los fallos y el contenido multimedia terminado
Una entrada que no se puede abrir o reconocer, la ausencia de una pista de audio o un proceso de
FFmpeg fallido hacen que convert.rb termine con un estado distinto de cero.
Los diagnósticos de FFmpeg identifican la entrada o salida involucrada. Una ejecución fallida puede
dejar archivos parciales en su nuevo directorio, y una conversión HLS exitosa seguida de un fallo
DASH deja los archivos HLS terminados. Publica solo después de que todo el script termine con éxito.
Para reintentar, usa nombres de directorio de salida nuevos en convert.rb y
actualiza las rutas de las fuentes del reproductor para que coincidan.
FFmpeg puede recuperarse de una entrada dañada y aun así devolver cero. Una conversión exitosa no es una comprobación de integridad: compara la imagen, el audio y la duración finales con la fuente. Inspecciona las tres variantes HLS y ambas representaciones de video DASH, no solo la calidad que seleccionó el reproductor. Comprueba que se pueda acceder a cada referencia de las listas de reproducción o los manifiestos y que la reproducción llegue al final esperado. El nombre de archivo de un manifiesto o la reproducción correcta del primer fotograma no demuestran que la reproducción sea completa.
Ve más allá del ejemplo local
El método HLS codifica la entrada tres veces de forma secuencial; DASH crea ambas representaciones de video en un solo proceso de FFmpeg. Empieza con una tarea de conversión y mide el uso de CPU, memoria y disco antes de añadir procesos de trabajo. Estas llamadas a subprocesos con comprobación de estado no proporcionan un tiempo de espera máximo ni un límite de aislamiento para las subidas no confiables. Un servicio de entrega en producción también necesita su propia configuración de publicación y HTTP; este tutorial verifica el empaquetado y la reproducción local.
Conclusión
Conserva los manifiestos terminados junto con sus segmentos al trasladar el resultado a tu entorno de publicación. Para usar encoding gestionado, explora el servicio de encoding de video de Transloadit.
