Convert videos to HLS and MPEG-DASH with Ruby and FFmpeg
Package a local video for on-demand playback using Ruby and FFmpeg. This example produces three HLS renditions, a master playlist with measured segment bandwidth, and a DASH manifest with two video representations. You will serve the completed files over loopback HTTP and play both formats in a browser.
Introduction to HLS and MPEG-DASH
HLS uses .m3u8 playlists; MPEG-DASH uses an .mpd manifest. Each index points to media segments.
Multiple compatible renditions let a player choose a quality, but packaging alone does not prove
smooth adaptation or reduced buffering on a real network. The bitrate ladder here is an example,
not a recommendation for every video.
Setting up your Ruby environment for video conversion
Use Bash on Linux with Ruby, RubyGems, FFmpeg, ffprobe, and Python on PATH. The tested environment
uses Ruby 3.4.10, FFmpeg/ffprobe 9.0.1, and Python 3.14.7. Choose a
maintained Ruby release; Ruby 3.2 has reached end of life.
Your FFmpeg build needs the libx264 and aac encoders and the HLS and DASH muxers:
ruby -v && gem -v && python3 --version &&
ffmpeg -version && ffprobe -version && ffmpeg -encoders && ffmpeg -muxers
Paste the following block into Bash from a writable directory. It creates a new project and installs
gems inside it using RubyGems’ explicit
installation directory options.
An existing video_conversion directory stops setup. A failed installation returns you to the
original directory; inspect the new directory before retrying rather than deleting existing work.
(
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
This standalone setup clears inherited Ruby loader settings inside the subshell, so an enclosing
Bundler project does not select its gems. The run command below uses the same isolation.
Copy a known-good local file into the project as input.mp4: use SDR H.264 video with square pixels,
a 16:9 picture at least 1280×720, and an audio track. Other aspect ratios would be stretched by this
fixed ladder. Choose a clip longer than 20 seconds to exercise several segment boundaries.
Silent videos need different mapping and playlist logic.
Converting videos to HLS format in Ruby
The Streamio FFMPEG gem supplies input inspection and encoder options. Its latest published version is 3.0.2, released in 2016, and its upstream compatibility notes target much older FFmpeg. This example tests its limited use with the versions above and invokes FFmpeg directly with an argument array and checked exit status. Each conversion creates a new output directory; an existing directory is rejected to protect earlier playlists and segments.
Create a new file called 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
After all three media playlists finish, Ruby writes master.m3u8. For each rendition, it measures
contiguous segment windows lasting between 0.5 and 1.5 times the playlist’s target duration and
rounds the largest rate up for BANDWIDTH, following the
HLS definition of peak segment bitrate.
This includes audio and MPEG-TS overhead. A short final segment is measured together with its
neighbor when it is too short to qualify alone; using the video encoder’s bitrate would omit part
of the delivered data.
Converting videos to MPEG-DASH in Ruby
The to_dash method above maps the input video twice, creating 720p and 360p representations, plus
one shared audio representation. Both video encodes use the same frame rate and force keyframes
every ten seconds, so the segment boundaries align. HLS uses the same keyframe schedule for its
three renditions. The final segment can be shorter than ten seconds. FFmpeg’s
HLS and
DASH muxer documentation explains the packaging options.
Using the converter
Save this second file as convert.rb, beside video_converter.rb and 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
Run from video_conversion:
env -u RUBYOPT -u RUBYLIB -u BUNDLE_GEMFILE \
GEM_HOME="$PWD/.gems" GEM_PATH="$PWD/.gems" ruby convert.rb
Wait for Conversion completed successfully. The result is
output/hls/master.m3u8, its three media playlists and .ts segments, plus
output/dash/manifest.mpd, initialization files, and .m4s segments. Keep each index with its
referenced files. Rerunning the script refuses the existing output/hls directory.
Testing the streams
Save this page as output/player.html. Each player has one source, so testing HLS cannot silently
stand in for testing DASH. Video.js HTTP Streaming
is included in the standard Video.js build and supports both formats through Media Source Extensions.
The library and stylesheet load from a CDN, so this page needs internet access.
<!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>
From video_conversion, start Python’s local HTTP server in the foreground:
python3 -m http.server 8080 --bind 127.0.0.1 --directory output
Open http://127.0.0.1:8080/player.html in a browser with H.264/AAC and Media Source Extensions
support. Play each video through to the end, then seek across a segment boundary. This page was
checked with Video.js 8.24.0 and Chromium 145 on Linux. Stop the server with Ctrl+C. If port 8080 is
occupied, choose another port in the command and use it in the URL. Python’s
HTTP server serves
only the output directory here; keep this local check limited to public test media.
Check failures and the finished media
An input that cannot be opened or recognized, a missing audio track, or a failed FFmpeg process
causes convert.rb to exit with a nonzero status. FFmpeg’s diagnostics name the input or output
involved. A failed run can leave partial files in its new directory, and an HLS success followed by
a DASH failure leaves the completed HLS files. Publish only after the whole script succeeds.
To retry, use fresh output directory names in convert.rb and update the player’s source paths to match.
FFmpeg can recover from damaged input and still return zero. Successful conversion is not an integrity check: compare the final picture, audio, and duration with your source. Inspect all three HLS renditions and both DASH video representations, not just the quality the player selected. Check that every playlist or manifest reference resolves, and that playback reaches the expected end. A manifest filename or a successful first frame does not establish complete playback.
Move beyond the local example
The HLS method encodes the input three times in sequence; DASH creates both video representations in one FFmpeg process. Start with one conversion job and measure CPU, memory, and disk use before adding workers. These checked subprocess calls do not provide a timeout or an isolation boundary for untrusted uploads. A production delivery service also needs its own publishing and HTTP setup; this walkthrough verifies packaging and local playback.
Conclusion
Keep the completed manifests and their segments together when moving the result to your publishing setup. For managed encoding, explore Transloadit’s video encoding service.
