HLS

When I started working on the Phlex on Rails video course, I tried streaming mp4 files from an S3 compatible object store and quickly found out from users they were running into issues watching the video. I added to use HLS, but I quickly found out it's a bit of a pain setting that up on a private object store.

Why?

Creating & serving HLS videos from private object stores is tricky.

Sane encoding defaults

When you encode a video into HLS format, it cranks out different resolutions and bitrates that play on everything from mobile phones to TVs. You give it an input video and it writes out all the chunks into a directory.

Generates pre-signed URLs in m3u8 playlists

The most annoying part about serving HLS videos from private object stores is generating pre-signed URLs for each chunk. This gem generates pre-signed URLs for each chunk in the m3u8 playlist, making it easy to serve HLS videos from private object stores.

Rails integration

A Railtie autoloads app/videos/*.rb profile classes, wires app-wide defaults from config/initializers/hls.rb, and ships an HLS::EncodeJob ActiveJob wrapper for queue-driven encoding.

Requirements

  • Ruby 3.1+
  • ffmpeg and ffprobe on the PATH (Homebrew, apt, or your distro equivalent)
  • An S3-compatible bucket (Tigris, Cloudflare R2, AWS S3, MinIO, etc.)

Support

Consider buying a video course from Beautiful Ruby and learn a thing or two to keep the machine going that originally built this gem.

Installation

Install the gem and add to the application's Gemfile by executing:

bundle add hls

If bundler is not being used to manage dependencies, install the gem by executing:

gem install hls

In a Rails app, scaffold the initial config + base profile with:

bin/rails g hls:install

This writes config/initializers/hls.rb (with env-var-driven bucket / S3 config) and app/videos/application_video.rb (the base class your profiles inherit from). Then generate per-content-type profiles:

bin/rails g hls:video Course
# => app/videos/course_video.rb

The generated profile has a sensible 3-rendition ladder, a hero poster, and inline comments for the common knobs. Edit to tune.

Usage

Declare a profile

In a Rails app, app/videos/*.rb is autoloaded and inherits app-wide defaults from config/initializers/hls.rb:

# config/initializers/hls.rb
require "aws-sdk-s3"

HLS.s3_resource = Aws::S3::Resource.new(
  access_key_id:     ENV.fetch("VIDEO_AWS_ACCESS_KEY_ID"),
  secret_access_key: ENV.fetch("VIDEO_AWS_SECRET_ACCESS_KEY"),
  endpoint:          ENV.fetch("VIDEO_S3_ENDPOINT_URL"),
  region:            "auto"
)

# app/videos/application_video.rb — bucket + signing TTL live on the
# storage adapter, configured here so every subclass under app/videos
# inherits them. Override `def self.storage` on a subclass to point
# at a different bucket.
class ApplicationVideo < HLS::ApplicationVideo
  def self.storage = HLS::Storage::S3.new(
    bucket_name: ENV.fetch("VIDEO_S3_BUCKET_NAME"),
    signing_ttl: 1.hour
  )

  segment_duration 4
end

# app/videos/course_video.rb
class CourseVideo < ApplicationVideo
  rendition :high,   scale: 1.0
  rendition :medium, scale: 0.5
  rendition :small,  scale: 0.25

  poster :hero,      scale: 1.0
  poster :thumbnail, width: 320, height: 180
end

Encode and upload

profile = CourseVideo.new(
  input: HLS::Input.new("lecture.mp4"),
  output: Pathname.new("tmp/encoded"),
  key_prefix: "courses/phlex/intro"
)
profile.process
# Probes input → encodes HLS multiplex + posters → uploads to bucket.
# Idempotent: re-running with the same input is a no-op.

Or enqueue the work asynchronously:

HLS::EncodeJob.perform_later(
  profile: "CourseVideo",
  input:  "lecture.mp4",
  output: "tmp/encoded",
  key_prefix: "courses/phlex/intro"
)

Serve from a controller

class VideosController < ApplicationController
  before_action { @manifest = CourseVideo.manifest(params[:id]) }

  def index
    respond_to do |format|
      format.m3u8 { render plain: @manifest.master_playlist }
      format.jpg  { redirect_to @manifest.poster_url(:hero), allow_other_host: true }
    end
  end

  def show
    variant = @manifest.variant(params[:variant])
    render plain: variant.playlist
  end
end

master_playlist rewrites variant URIs to a controller-routable shape; variant.playlist rewrites segment URIs to pre-signed S3 URLs that the player can fetch directly.

Preview windows

Variant#[range] slices the variant to a duration in seconds. Useful for locked-content previews:

PREVIEW_DURATION = 30.seconds
list = if subscriber?
  @manifest.variant(params[:variant]).playlist
else
  @manifest.variant(params[:variant])[0...PREVIEW_DURATION].playlist
end

Testing your profile

The gem ships test helpers for verifying that your profile produces a correct bundle end-to-end:

require "hls/testing"

RSpec.describe CourseVideo do
  include HLS::Testing

  it "produces a complete HLS bundle" do
    video = generate_test_video(duration: 12)
    output = Pathname.new(Dir.mktmpdir)

    profile = CourseVideo.new(input: HLS::Input.new(video), output: output)
    silence_ffmpeg { profile.encode!; profile.poster! }

    expect(output).to be_a_valid_hls_bundle
      .with_variants(3)
      .with_posters(:hero, :thumbnail)
  end
end

Codec auto-detection

By default video_codec :h264 resolves to the best available encoder on the host (h264_videotoolbox on macOS, h264_nvenc/h264_qsv on Linux GPUs, libx264 as the universal fallback). Override per-profile when you need an explicit one:

class WebVideo < ApplicationVideo
  video_codec "libx264"   # always software, regardless of host
end

Configuration reference

Every class-level setting on HLS::ApplicationVideo is inheritable through the class hierarchy. For static values, set with the DSL form (segment_duration 4); for dynamic values that should re-read on every Zeitwerk reload, override the reader (def self.storage = ...).

Setting Default Notes
storage none — required HLS::Storage::S3, HLS::Storage::Memory, or any conforming adapter
segment_duration 4 HLS segment length, seconds
video_codec :h264 Symbol (auto-resolved) or string (explicit)
audio_codec "aac"
audio_bitrate 128 kbps
bits_per_pixel :mixed (4) :screencast (3), :mixed (4), :motion (6)
max_bitrate_kbps 15_000 Caps scaled-rendition bitrate
ffmpeg_timeout nil Hard cap (seconds) on a single ffmpeg run; nil disables
cache nil HLS::Cache (groups a Rails.cache-shaped backend with a TTL) or any object responding to fetch(key, &block)

HLS::Storage::S3 itself takes bucket_name:, signing_ttl:, and an optional s3_resource: (defaults to HLS.s3_resource). For tests or non-AWS backends, use HLS::Storage::Memory.new(name: ...) or any object responding to signing_ttl and object(key).

HLS.s3_resource is process-global. If two profiles need different SDK clients (different regions, credentials, or endpoints), pass s3_resource: directly to each HLS::Storage::S3 rather than relying on the global default:

class CourseVideo < ApplicationVideo
  def self.storage = HLS::Storage::S3.new(
    s3_resource: Aws::S3::Resource.new(region: "us-west-2", ...),
    bucket_name: "course-videos",
    signing_ttl: 1.hour
  )
end

Concurrency and retries

The uploader runs PUTs in parallel with bounded concurrency and retries transient failures with exponential backoff. Defaults are tuned for typical home/office connections; override per-call:

HLS::Uploader.new(
  storage: storage, output: out, key_prefix: prefix, state: state,
  concurrency: 8,        # default 4
  max_retries: 5,        # default 3
  initial_backoff: 0.25  # default 0.5 seconds, doubles per attempt
).perform

Only transient errors (network timeouts, 503s, throttling) are retried; permanent errors (NoSuchBucket, 403) fail fast.

Instrumentation

The gem publishes ActiveSupport::Notifications events when AS is loaded. Subscribe to wire up logging or metrics:

ActiveSupport::Notifications.subscribe("encode.hls") do |name, start, finish, _, payload|
  Rails.logger.info "[hls] encoded #{payload[:profile]} in #{((finish - start) * 1000).round}ms"
end

Events:

Name Payload keys
encode.hls profile, output, renditions
poster.hls profile, output, count
verify.hls profile, output
upload_object.hls key, bytes, content_type
upload_retry.hls key, attempt, error, message
process.hls profile, key_prefix, uploaded, skipped

Storage adapters

The default backend is HLS::Storage::S3, which wraps an Aws::S3::Bucket (works with AWS S3, Tigris, Cloudflare R2, MinIO). HLS::Storage::Memory ships as a no-network adapter useful for tests. Roll your own by implementing the protocol — signing_ttl plus object(key) returning something that responds to get, put(body:, content_type:, cache_control:), and presigned_url(:get, expires_in:).

MinIO

MinIO is API-compatible with S3 — point HLS.s3_resource at its endpoint:

# config/initializers/hls.rb
HLS.s3_resource = Aws::S3::Resource.new(
  access_key_id:     ENV["MINIO_ACCESS_KEY"],
  secret_access_key: ENV["MINIO_SECRET_KEY"],
  endpoint:          ENV["MINIO_ENDPOINT"], # e.g. http://localhost:9000
  region:            "us-east-1",
  force_path_style:  true                   # required for MinIO
)

# app/videos/application_video.rb
class ApplicationVideo < HLS::ApplicationVideo
  def self.storage = HLS::Storage::S3.new(
    bucket_name: ENV.fetch("MINIO_BUCKET"),
    signing_ttl: 1.hour
  )
end

force_path_style: true is the key MinIO requirement — MinIO doesn't do virtual-hosted-style addressing.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/beautifulruby/hls.