Converter vídeos em HLS e MPEG-DASH com Ruby e FFmpeg
Empacote um vídeo local para reprodução sob demanda usando Ruby e FFmpeg. Este exemplo gera três variantes HLS, uma playlist master com a largura de banda medida dos segmentos e um manifesto DASH com duas representações de vídeo. Você vai servir os arquivos prontos via HTTP em loopback e reproduzir os dois formatos em um navegador.
Introdução ao HLS e ao MPEG-DASH
O HLS usa playlists .m3u8; o MPEG-DASH usa um manifesto .mpd. Cada índice aponta para segmentos de mídia.
Várias variantes compatíveis permitem que o player escolha uma qualidade, mas o empacotamento por
si só não comprova uma adaptação suave nem menos buffering em uma rede real. A escada de bitrates
usada aqui é um exemplo, não uma recomendação para todo vídeo.
Configuração do ambiente Ruby para conversão de vídeo
Use o Bash no Linux com Ruby, RubyGems, FFmpeg, ffprobe e Python no PATH. O ambiente testado
usa Ruby 3.4.10, FFmpeg/ffprobe 9.0.1 e Python 3.14.7. Escolha uma
versão do Ruby com manutenção ativa; o Ruby 3.2 já chegou ao fim da vida útil.
Seu build do FFmpeg precisa dos encoders libx264 e aac e dos muxers HLS e DASH:
ruby -v && gem -v && python3 --version &&
ffmpeg -version && ffprobe -version && ffmpeg -encoders && ffmpeg -muxers
Cole o bloco a seguir no Bash a partir de um diretório com permissão de escrita. Ele cria um novo
projeto e instala as gems dentro dele usando as
opções de diretório de instalação explícitas do RubyGems.
Se o diretório video_conversion já existir, a configuração é interrompida. Uma instalação com falha leva você de
volta ao diretório original; inspecione o novo diretório antes de tentar novamente, em vez de
apagar trabalho 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
Essa configuração independente limpa, dentro do subshell, as configurações herdadas do loader do
Ruby, para que um projeto Bundler externo não selecione as gems dele. O comando de execução abaixo
usa o mesmo isolamento.
Copie para o projeto um arquivo local que sabidamente funciona, com o nome input.mp4: use vídeo H.264 SDR
com pixels quadrados, imagem 16:9 de pelo menos 1280×720 e uma faixa de áudio. Outras proporções
seriam esticadas por essa escada fixa. Escolha um clipe com mais de 20 segundos para passar por
vários limites de segmento. Vídeos sem áudio exigem outra lógica de mapeamento e de playlist.
Conversão de vídeos para o formato HLS em Ruby
A gem Streamio FFMPEG fornece a inspeção da entrada e as opções do encoder. A versão publicada mais recente é a 3.0.2, lançada em 2016, e as notas de compatibilidade upstream dela se referem a versões bem mais antigas do FFmpeg. Este exemplo testa o uso limitado da gem com as versões acima e chama o FFmpeg diretamente com um array de argumentos e verificação do status de saída. Cada conversão cria um novo diretório de saída; um diretório existente é rejeitado para proteger playlists e segmentos anteriores.
Crie um novo arquivo chamado 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
Depois que as três playlists de mídia terminam, o Ruby grava master.m3u8. Para cada variante, ele mede
janelas de segmentos contíguos com duração entre 0,5 e 1,5 vez a duração-alvo da playlist e
arredonda para cima a maior taxa em BANDWIDTH, seguindo a
definição de bitrate de pico de segmento do HLS.
Isso inclui o áudio e o overhead do MPEG-TS. Quando o segmento final é curto demais para se
qualificar sozinho, ele é medido junto com o segmento vizinho; usar o bitrate do encoder de vídeo
omitiria parte dos dados entregues.
Conversão de vídeos para MPEG-DASH em Ruby
O método to_dash acima mapeia o vídeo de entrada duas vezes, criando representações em 720p e 360p,
além de uma representação de áudio compartilhada. As duas codificações de vídeo usam a mesma taxa
de quadros e forçam keyframes a cada dez segundos, para que os limites dos segmentos fiquem
alinhados. O HLS usa o mesmo cronograma de keyframes nas três variantes. O segmento final pode ter
menos de dez segundos. A documentação dos muxers HLS e
DASH do FFmpeg explica as opções de empacotamento.
Uso do conversor
Salve este segundo arquivo como convert.rb, ao lado de video_converter.rb e 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
Execute a partir de video_conversion:
env -u RUBYOPT -u RUBYLIB -u BUNDLE_GEMFILE \
GEM_HOME="$PWD/.gems" GEM_PATH="$PWD/.gems" ruby convert.rb
Aguarde Conversion completed successfully. O resultado é
output/hls/master.m3u8, com as três playlists de mídia e os segmentos .ts, além de
output/dash/manifest.mpd, dos arquivos de inicialização e dos segmentos .m4s. Mantenha cada índice junto
com os arquivos que ele referencia. Se você executar o script novamente, ele recusará o diretório
output/hls existente.
Teste dos streams
Salve esta página como output/player.html. Cada player tem uma única fonte, então o teste do HLS não pode
substituir silenciosamente o teste do DASH.
O Video.js HTTP Streaming vem incluído no build padrão do Video.js e
oferece suporte aos dois formatos por meio de Media Source Extensions. A biblioteca e a folha de
estilo são carregadas de uma CDN, então esta página precisa de acesso à 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>
A partir de video_conversion, inicie o servidor HTTP local do Python em primeiro plano:
python3 -m http.server 8080 --bind 127.0.0.1 --directory output
Abra http://127.0.0.1:8080/player.html em um navegador com suporte a H.264/AAC e Media Source Extensions.
Reproduza cada vídeo até o fim e depois salte na linha do tempo para além de um limite de segmento.
Esta página foi verificada com Video.js 8.24.0 e Chromium 145 no Linux. Pare o servidor com
Ctrl+C. Se a porta 8080 estiver ocupada, escolha outra porta no comando e use-a na URL.
O servidor HTTP do Python serve apenas o diretório output aqui; limite esta
verificação local a mídias de teste públicas.
Verifique falhas e a mídia final
Uma entrada que não pode ser aberta nem reconhecida, a falta de uma faixa de áudio ou uma falha no
processo do FFmpeg faz convert.rb encerrar com status de saída diferente de zero. Os diagnósticos do
FFmpeg indicam a entrada ou a saída envolvida. Uma execução com falha pode deixar arquivos parciais
no novo diretório dela, e um sucesso no HLS seguido de uma falha no DASH deixa os arquivos HLS
concluídos. Publique somente depois que o script inteiro for concluído com sucesso.
Para tentar novamente, use novos nomes de diretório de saída em convert.rb e atualize os caminhos das
fontes do player de acordo.
O FFmpeg pode se recuperar de uma entrada danificada e ainda assim retornar zero. Uma conversão bem-sucedida não é uma verificação de integridade: compare a imagem final, o áudio e a duração com os do seu vídeo de entrada original. Inspecione as três variantes HLS e as duas representações de vídeo DASH, não apenas a qualidade que o player selecionou. Verifique se todas as referências de playlist ou de manifesto são resolvidas e se a reprodução chega ao fim esperado. O nome de arquivo de um manifesto ou um primeiro quadro exibido com sucesso não comprova a reprodução completa.
Vá além do exemplo local
O método HLS codifica a entrada três vezes em sequência; o DASH cria as duas representações de vídeo em um único processo do FFmpeg. Comece com uma única tarefa de conversão e meça o uso de CPU, memória e disco antes de adicionar mais workers. Essas chamadas de subprocesso verificadas não oferecem timeout nem uma barreira de isolamento para uploads não confiáveis. Um serviço de entrega em produção também precisa da própria configuração de publicação e de HTTP; este passo a passo verifica o empacotamento e a reprodução local.
Conclusão
Mantenha os manifestos concluídos e os segmentos deles juntos ao mover o resultado para a sua infraestrutura de publicação. Para codificação gerenciada, conheça o serviço de codificação de vídeo da Transloadit.
