DEV Community

Cover image for Creating a VOD (Video On Demand) Platform with Rails & FFMPEG
Adam Katora
Adam Katora

Posted on

Creating a VOD (Video On Demand) Platform with Rails & FFMPEG

If you want the TL;DR version, here's the github link


Intro

Recently, I started working on a project where I wanted to allow users to upload videos, then allow them and other users to playback those videos. I was able to find numerous tutorials showing how to make Youtube clones in rails, but nearly every tutorial went along the lines of "upload video as .mp4, save the video to storage, play back video as mp4".

While this approach can work in limited instances, it has some serious drawbacks. For one, mp4 files have no way to truly be streamed. Sure, there's modern tricks to fragment mp4 files to give the illusion of streaming, but this solution hardly holds up in low bandwidth network settings, and mobile support can be hit or miss.

Instead, the HTTP Live Streaming (HLS), format developed by Apple, provides a way for video to sent to the client in an adaptive format that allows quality to be automatically adjusted depending on the client's internet connection. HLS files are also segmented by default, which provides the benefit of reducing the bandwidth required to start playing video.

The below tutorial for building a rails VOD platform is by no means production ready, but it should serve as a good jumping off point for anyone who's looking to build out a more fully-featured rails video service.

So with that, let's get started.


The version of Ruby & Rails I'm using:

Ruby Version: 3.0.2

Rails Version: 6.1.4.1

For a sample mp4 video, I'm using this 1080p first 30s version of the famous blender made video, big buck bunny


Project Setup

The instructions I'll be providing for this setup run the app inside a docker container.

Originally, I was developing this project on MacOS, but ran into permissions issues with the streamio-ffmpeg gem accessing the system installed version of FFMPEG. Instead of wasting time troubleshooting that, I decided this was a good opportunity to dockerize my app.

To start create a folder with your desired project name, ie:

mkdir rails-vod-example && cd rails-vod-example
Enter fullscreen mode Exit fullscreen mode

Create a Dockerfile in your project root:

touch Dockerfile
Enter fullscreen mode Exit fullscreen mode

Once the Dockerfile has been created, add the following code.

Dockerfile

FROM ruby:3.0.2

RUN apt-get update -qq \
    && apt-get install -y ffmpeg nodejs \
    npm

ADD . /app
WORKDIR /app
COPY Gemfile /app/Gemfile
COPY Gemfile.lock /app/Gemfile.lock
RUN bundle install
RUN npm install -g yarn

EXPOSE 3000
CMD ["bash"]
Enter fullscreen mode Exit fullscreen mode

We'll then need to create a Gemfile with Rails in our project root so that we can run rails new inside our container.

touch Gemfile
Enter fullscreen mode Exit fullscreen mode

And add the following to the newly created Gemfile (this will get overwritten once we run rails new in the container)
Gemfile

source 'https://rubygems.org'
gem 'rails', '~>6'
Enter fullscreen mode Exit fullscreen mode

Create a Gemfile.lock as well

touch Gemfile.lock
Enter fullscreen mode Exit fullscreen mode

Next create docker-compose.yml file in your project root and add the following configuration files to it.

touch docker-compose.yml
Enter fullscreen mode Exit fullscreen mode

docker-compose.yml

version: '3.8'
services:
  web:
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/app
    ports:
      - "3000:3000"
Enter fullscreen mode Exit fullscreen mode

Run this command to

docker-compose run --no-deps web rails new . --force
Enter fullscreen mode Exit fullscreen mode

With the new project files generated run the following command which will change the ownership of the newly created rails files from root to your user. (Docker runs as root so the rails new command generates the files as the root user)

sudo chown -R $USER:$USER .
Enter fullscreen mode Exit fullscreen mode

Then you'll need to reinstall the new Gemfile dependencies, run docker-compose build to re-run the bundle install

docker-compose build
Enter fullscreen mode Exit fullscreen mode

Once that's complete, running docker-compose up should start the rails app and make it accessible on localhost:3000/


Building the bones of our app

For storage, we're going to use the Shrine gem, and for video transcoding we'll use ffmpeg via the streamio-ffmpeg gem.

Add these two lines to your Gemfile, then install them with docker-compose build.

gem 'shrine', '~> 3.0'
gem 'streamio-ffmpeg', '~> 3.0'
Enter fullscreen mode Exit fullscreen mode

Next, we're going to use Rails scaffold to generate CRUD operations for our Video model. We add the name:string to create a new name field for our video and original_video_data:text to add the field Shrine needs for storing data about our uploaded file.

rails g scaffold Video name:string original_video_data:text
Enter fullscreen mode Exit fullscreen mode

If your curious about what the orginal_video_data field is, and why it's suffixed with _data, I'd recommend reading the Shrine Getting Started docs as our code so far closely follows that.


In addition to creating the controllers, models, views, etc. The scaffold command will also create the neccessary routes for our Videos to be accessible at the /videos endpoint.

Let's add a default route so we don't have to type /videos everytime we want to test our application.

Update your config/routes.rb code to look like the below:

config/routes.rb

Rails.application.routes.draw do
  root to: "videos#index"
  resources :videos
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
Enter fullscreen mode Exit fullscreen mode

Configuring Shrine:

To configure Shrine, we need to start by creating a Shrine initializer. I'm not going to go into too much detail about how Shrine works here, but the gist of things is that the below commands create two Shrine stores, a cache and permanent.

With the settings provided below Shrine is configured to use the local file system, and the cache and permanent storage are located in the Rails public/ directory and then placed into public/uploads & public/uploads/cache due to the prefix settings.

Create a new file config/initializers/shrine.rb and add the following code:

config/initializers/shrine.rb

require "shrine"

# File System Storage
require "shrine/storage/file_system"
Shrine.storages = { 
  cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"), # temporary 
  store: Shrine::Storage::FileSystem.new("public", prefix: "uploads"),       # permanent 
}

Shrine.plugin :activerecord 
Shrine.plugin :cached_attachment_data # for retaining the cached file across form redisplays 
Shrine.plugin :restore_cached_data # re-extract metadata when attaching a cached file 
Enter fullscreen mode Exit fullscreen mode

After configuring Shrine, let's test our rails app is running correctly by typing the command

docker-compose run web rails db:create db:migrate
Enter fullscreen mode Exit fullscreen mode

Then start our rails server with:

docker-compose up
Enter fullscreen mode Exit fullscreen mode

If all is well, you should see something similar to the below image:

Rails App Video Index

With everything running smoothly, the next step is to create our Uploader. Start by creating a new folder in the /app directory called uploaders, and create a new .rb file for our uploader called video_uploader.rb

In a minute, this is where we will add the processing logic for how to handle video uploads, but for now, let's start with the bare minimum by adding the following code to video_uploader.rb

app/uploaders/video_uploader.rb

class VideoUploader < Shrine

end
Enter fullscreen mode Exit fullscreen mode

Before we go any further with the processing logic for our uploader, let's make sure it's connected to our Video model. Shrine makes this incredibly easy. In app/models/video.rb add the following code so that your model looks like the below:

class Video < ApplicationRecord
  include VideoUploader::Attachment(:original_video)
end
Enter fullscreen mode Exit fullscreen mode

If you'll recall, when we created our Video object earlier, we named the field in the database original_video_data, but in the model file we use just original_video instead. The _data suffix is a Shrine naming convention, hence the reason for it.

In app/views/videos/_form.html.erb, update the form field for the original_video file field to the below code. According to Shrine docs, the hidden_field ensures that if a user updates a video object without uploading a new orginal_video file, the cached file gets used instead of being overwritten to an empty file.

app/views/videos/_form.html.erb

<div class="field">
  <%= form.label :original_video, "Video File" %>
  <%= form.hidden_field :original_video, value: video.cached_original_video_data %>
  <%= form.file_field :original_video %>
</div>
Enter fullscreen mode Exit fullscreen mode

The updated _form.html.erb should look like this:

<%= form_with(model: video) do |form| %>
  <% if video.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(video.errors.count, "error") %> prohibited this video from being saved:</h2>

      <ul>
        <% video.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>
  <div class="field">
    <%= form.label :name %>
    <%= form.text_field :name %>
  </div>

  <div class="field">
    <%= form.label :original_video, "Video File" %>
    <%= form.hidden_field :original_video, value: video.cached_original_video_data %>
    <%= form.file_field :original_video %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

And finally, in our Videos Controller, we have to remember to permit the new :original_video parameter so that the file can actually be saved.

Update the video_params to this:

# Only allow a list of trusted parameters through.
def video_params
  params.require(:video).permit(:name, :original_video)
end
Enter fullscreen mode Exit fullscreen mode

The full controller should now look like this:

app/controllers/videos_controller.rb

class VideosController < ApplicationController
  before_action :set_video, only: %i[ show edit update destroy ]

  # GET /videos or /videos.json
  def index
    @videos = Video.all
  end

  # GET /videos/1 or /videos/1.json
  def show
  end

  # GET /videos/new
  def new
    @video = Video.new
  end

  # GET /videos/1/edit
  def edit
  end

  # POST /videos or /videos.json
  def create
    @video = Video.new(video_params)

    respond_to do |format|
      if @video.save
        format.html { redirect_to @video, notice: "Video was successfully created." }
        format.json { render :show, status: :created, location: @video }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @video.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /videos/1 or /videos/1.json
  def update
    respond_to do |format|
      if @video.update(video_params)
        format.html { redirect_to @video, notice: "Video was successfully updated." }
        format.json { render :show, status: :ok, location: @video }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @video.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /videos/1 or /videos/1.json
  def destroy
    @video.destroy
    respond_to do |format|
      format.html { redirect_to videos_url, notice: "Video was successfully destroyed." }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_video
      @video = Video.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def video_params
      params.require(:video).permit(:name, :original_video)
    end
end
Enter fullscreen mode Exit fullscreen mode

With all the above added, we can now test that Shrine is uploading and saving our .mp4 file. Make sure your rails server is running, then navigate to localhost:3000


NOTE: In rails, the initializers get called... well, on initialization. That means if you make changes to your shrine.rb file, make sure to stop and restart your server so that the changes will take affect.


At the videos index, click "New Video", give your video a name, and then select an mp4 file to upload.

Just remember, since we haven't done any frontend work (like adding a file upload progress bar) there won't be any feedback as the file gets uploaded. So just make sure to use a small .mp4 file for testing purposes.

Once the file uploads, you should get a green confirmation message that the video was created successfully. Let's update our show view, so we can see more information about our video.

app/views/videos/show.html.erb

<p id="notice"><%= notice %></p>

<p><strong>Name: </strong><%= @video.name %></p>

<p><strong>@video.original_video_url</strong> <%= @video.original_video_url %></p>

<%= link_to 'Edit', edit_video_path(@video) %> |
<%= link_to 'Back', videos_path %>
Enter fullscreen mode Exit fullscreen mode

Updating the show view to the above code and navigating to localhost:3000/videos/1 you should see page similar to the following:

Alt Text


NOTE: Shrine automatically generates a UUID for file uploads but retains the original filename in the _data field. That's why the filename is different.


And if you navigate to your /public/uploads folder in your project, you should see that your .mp4 has been uploaded with it's Shrine ID as the filename.

For housekeeping andtesting purposes, go ahead and hit "Back" then use the "Destroy" button to delete your file. You'll notice it removes the item from the database, and deletes the associated video file from the permanent storage. (You might notice that the cache file & folder is still there, but handling that is outside the scope of what I'm aiming to cover in this tutorial)


Building our HLS VOD Service

NOTE: At the time of writing this, I had done some, albeit basic, refactoring to the code to make it a little more DRY. Instead of going step by step through the trial and error I went through to end up with this code, I've decided to just present the completed version as-is, then at the end explain some of the earlier roadblocks and why I made certain decisions


With the basic CRUD and upload functionality of our VOD service in place, it's time build out the actual HLS processing logic in our VideoUploader class.

Getting started:

We'll need to extend Shrine with two plugins, processing & versions. The processing plugin exposes a method process to our VideoUploader class that allows us to apply transforming processes to our file everytime a new file is uploaded.

The versions plugin further extends the processing plugin by giving us the ability to store arrays of files in our object. The plugin being named "versions" can be a bit misleading, but as you'll see from our use-case, we can use it beyond just versioning files.

At the top of the VideoUploader class, add the versions and processing plugins to Shrine:

class VideoUploader < Shrine
  plugin :versions
  plugin :processing

  ...
Enter fullscreen mode Exit fullscreen mode

Next, we're going to override the initialize method in our uploader to generate a uuid which we will use for our transcoding ouput folder & filename. Add the following code to your VideoUploader class below the plugins.

def initialize(*args)
    super
    @@_uuid = SecureRandom.uuid
end
Enter fullscreen mode Exit fullscreen mode

Now we're going to override Shrine's generate_location method in order to customize the file path our transcoded videos are saved at.

To briefly explain, generate_location gets called for each file that gets saved. Later in our code we'll be adding an array, called hls_playlist, to hold all the .ts and .m3u8 files that get generated by ffmpeg. So generate_location gets called for each one of those files that gets added.

The generate_location function then checks if the file being saved is of type .ts or .m3u8, and if it is saves it with the name of the file. (In this case the filename will be the uuid with some additional information about the encoding options appended as we'll see later)

Here's the code for generate_location to be added to the VideoUploader class.

def generate_location(io, record: nil, **)
    basename, extname = super.split(".")
    if extname == 'ts' || extname == 'm3u8'
        location = "#{@@_uuid}/#{File.basename(io.to_path)}"
    else
        location = "#{@@_uuid}/#{@@_uuid}.#{extname.to_s}"
    end
end
Enter fullscreen mode Exit fullscreen mode

Next we're going to create a method to generate our HLS playlist, and add that newly created playlist to the master .m3u8 file. This method requires that an .m3u8 file already be open (which we pass in as the master_playlist param) and that a streamio-ffmpeg movie object already be created (which we pass in with the movie param)

The path and uuid params are both generated from the uuid that we created in the initialize method.

We set the options for ffmpeg in its own variable, and use the custom attribute to pass in cli options to ffmpeg for encoding the hls stream. Using %W instead of %w allows us to pass in variables to the array via Ruby string interpolation.

In the ffmpeg custom array, the flag -vf scale=#{width}:-2 allows us to specify a new video width while retaining the original videos aspect ratio. I'm not going to go over the rest of the ffmpeg settings in here, other than just to say these settings definitely need to be tweaked before actually being used.

In the movie.transcode call the first option is the output path of the file (you can see we join the path variable to gets passed to the function, with the uuid then the transcoding settings of the file)

After FFMPEG transcodes the movie, we use Ruby's Dir.glob to iterate over each of the generated .ts files and get the value of the highest bandwidth and calculate the average bandwidth before writing those and the rest of the .m3u8 information into the master.m3u8 playlist file.

def generate_hls_playlist(movie, path, uuid, width, bitrate, master_playlist)
    ffmpeg_options = {validate: false, custom: %W(-profile:v baseline -level 3.0 -vf scale=#{width}:-2 -b:v #{bitrate} -start_number 0 -hls_time 2 -hls_list_size 0 -f hls) }
    transcoded_movie = movie.transcode(File.join("#{path}", "#{uuid}-w#{width}-#{bitrate}.m3u8"), ffmpeg_options)

    bandwidth = 0
    avg_bandwidth = 0
    avg_bandwidth_counter = 0

    Dir.glob("#{path}/#{uuid}-w#{width}-#{bitrate}*.ts") do |ts|
        movie = FFMPEG::Movie.new(ts)
        if movie.bitrate > bandwidth
            bandwidth = movie.bitrate
        end
        avg_bandwidth += movie.bitrate
        avg_bandwidth_counter += 1
    end

    avg_bandwidth = (avg_bandwidth / avg_bandwidth_counter)

    master_playlist.write("#EXT-X-STREAM-INF:BANDWIDTH=#{bandwidth},AVERAGE-BANDWIDTH=#{avg_bandwidth},CODECS=\"avc1.640028,mp4a.40.5\",RESOLUTION=#{transcoded_movie.resolution},FRAME-RATE=#{transcoded_movie.frame_rate.to_f}\n")
    master_playlist.write(File.join("/uploads", uuid, "#{File.basename(transcoded_movie.path)}\n") )
end
Enter fullscreen mode Exit fullscreen mode

Whew. still with me?

Finally, we use the process method that we called earlier to run the video transcoding on our uploaded file.

Using the versions plugin we create a variable versions which is a hash that contains both the original file, and an array we're naming hls_playlist that will hold all of the .ts and .m3u8 files that ffmpeg generates.

We use io.download to get the original file, then open up our new FFMPEG::Movie object as a variable movie, and create the master.m3u8 playlist file and write the required first lines.

Next we make a call to the method we just wrote to transcode our video, generate_hls_playlist

process(:store) do |io, **options|
    versions = { original: io, hls_playlist: [] }

    io.download do |original|
        path = File.join(Rails.root, "tmp", "#{@@_uuid}")
        FileUtils.mkdir_p(path) unless File.exist?(path)

        movie = FFMPEG::Movie.new(original.path)

        master_playlist = File.open(File.join("#{path}","#{@@_uuid}-master.m3u8"), "w")
        master_playlist.write("#EXTM3U\n")
        master_playlist.write("#EXT-X-VERSION:3\n")
        master_playlist.write("#EXT-X-INDEPENDENT-SEGMENTS\n")

        generate_hls_playlist(movie, path, @@_uuid, 720, "2000k", master_playlist)
        generate_hls_playlist(movie, path, @@_uuid, 1080, "4000k", master_playlist)

        versions[:hls_playlist] << File.open(master_playlist.path)

        Dir.glob("#{path}/#{@@_uuid}-w*.m3u8") do |m3u8|
            versions[:hls_playlist] << File.open(m3u8)
        end
        Dir.glob("#{path}/#{@@_uuid}*.ts").each do |ts|
            versions[:hls_playlist] << File.open(ts)
        end

        master_playlist.close
        FileUtils.rm_rf("#{path}")
    end
    versions
end
Enter fullscreen mode Exit fullscreen mode

With all that code added, your video_uploader.rb file should look like the following:

class VideoUploader < Shrine
    plugin :versions
    plugin :processing

    def initialize(*args)
        super
        @@_uuid = SecureRandom.uuid
    end

    def generate_location(io, record: nil, **)
        basename, extname = super.split(".")
        if extname == 'ts' || extname == 'm3u8'
            location = "#{@@_uuid}/#{File.basename(io.to_path)}"
        else
            location = "#{@@_uuid}/#{@@_uuid}.#{extname.to_s}"
        end
    end

    def generate_hls_playlist(movie, path, uuid, width, bitrate, master_playlist)
        ffmpeg_options = {validate: false, custom: %W(-profile:v baseline -level 3.0 -vf scale=#{width}:-2 -b:v #{bitrate} -start_number 0 -hls_time 2 -hls_list_size 0 -f hls) }
        transcoded_movie = movie.transcode(File.join("#{path}", "#{uuid}-w#{width}-#{bitrate}.m3u8"), ffmpeg_options)

        bandwidth = 0
        avg_bandwidth = 0
        avg_bandwidth_counter = 0

        Dir.glob("#{path}/#{uuid}-w#{width}-#{bitrate}*.ts") do |ts|
            movie = FFMPEG::Movie.new(ts)
            if movie.bitrate > bandwidth
                bandwidth = movie.bitrate
            end
            avg_bandwidth += movie.bitrate
            avg_bandwidth_counter += 1
        end

        avg_bandwidth = (avg_bandwidth / avg_bandwidth_counter)

        master_playlist.write("#EXT-X-STREAM-INF:BANDWIDTH=#{bandwidth},AVERAGE-BANDWIDTH=#{avg_bandwidth},CODECS=\"avc1.640028,mp4a.40.5\",RESOLUTION=#{transcoded_movie.resolution},FRAME-RATE=#{transcoded_movie.frame_rate.to_f}\n")
        master_playlist.write(File.join("/uploads", uuid, "#{File.basename(transcoded_movie.path)}\n") )
    end

    process(:store) do |io, **options|
        versions = { original: io, hls_playlist: [] }

        io.download do |original|
            path = File.join(Rails.root, "tmp", "#{@@_uuid}")
            FileUtils.mkdir_p(path) unless File.exist?(path)

            movie = FFMPEG::Movie.new(original.path)

            master_playlist = File.open(File.join("#{path}","#{@@_uuid}-master.m3u8"), "w")
            master_playlist.write("#EXTM3U\n")
            master_playlist.write("#EXT-X-VERSION:3\n")
            master_playlist.write("#EXT-X-INDEPENDENT-SEGMENTS\n")

            generate_hls_playlist(movie, path, @@_uuid, 720, "2000k", master_playlist)
            generate_hls_playlist(movie, path, @@_uuid, 1080, "4000k", master_playlist)

            versions[:hls_playlist] << File.open(master_playlist.path)

            Dir.glob("#{path}/#{@@_uuid}-w*.m3u8") do |m3u8|
                versions[:hls_playlist] << File.open(m3u8)
            end
            Dir.glob("#{path}/#{@@_uuid}*.ts").each do |ts|
                versions[:hls_playlist] << File.open(ts)
            end

            master_playlist.close
            FileUtils.rm_rf("#{path}")
        end
        versions
    end
end
Enter fullscreen mode Exit fullscreen mode

Restart your rails server, open up to the Videos index, create a new video, and let it upload.

After the video uploads you should see the following rails error screen:
Alt Text

Believe it or not, this is actually what we want to see. When we added the :versions plugin to our VideoUploader class, it changed the way that we need to access the file from our erb code.

Update the app/views/videos/show.html.erb to be the following. Note that since :hls_playlist is an array, we're calling .first.url, because when we created the :hls_playlist we set the master.m3u8 file to be the first element in the array.

<p id="notice"><%= notice %></p>

<p><strong>Name: </strong><%= @video.name %></p>

<p><strong>@video.original_video[:hls_playlist].first.url</strong> <%= @video.original_video[:hls_playlist].first.url %></p>

<%= link_to 'Edit', edit_video_path(@video) %> |
<%= link_to 'Back', videos_path %>
Enter fullscreen mode Exit fullscreen mode

With that update made, navigating back to the show page for your newly uploaded video should echo back the url of the master .m3u8 playlist file. That's great, but now how do we actually play it?

In a real app, you'd want to pick a video player for your frontend of choice that supports HLS, but to quickly see what we're working with, I'm going to use a cdn version of HLS.js to get us up and running with a video player.

Add the following code to your show view, it's the example code from the hls.js github modified to use our :hls_playlist uploaded file as the video source.

<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/0.5.14/hls.min.js" integrity="sha512-js37JxjD6gtmJ3N2Qzl9vQm4wcmTilFffk0nTSKzgr3p6aitg73LR205203wTzCCC/NZYO2TAxSa0Lr2VMLQvQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

<video id="video" controls></video>
<script>
  var video = document.getElementById('video');
  var videoSrc = '<%= @video.original_video[:hls_playlist].first.url %>';
  if (Hls.isSupported()) {
    console.log("HLS Supported")
    var hls = new Hls();
    hls.loadSource(videoSrc);
    hls.attachMedia(video);
  }
  // HLS.js is not supported on platforms that do not have Media Source
  // Extensions (MSE) enabled.
  //
  // When the browser has built-in HLS support (check using `canPlayType`),
  // we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video
  // element through the `src` property. This is using the built-in support
  // of the plain video element, without using HLS.js.
  //
  // Note: it would be more normal to wait on the 'canplay' event below however
  // on Safari (where you are most likely to find built-in HLS support) the
  // video.src URL must be on the user-driven white-list before a 'canplay'
  // event will be emitted; the last video event that can be reliably
  // listened-for when the URL is not on the white-list is 'loadedmetadata'.
  else if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.src = videoSrc;
  }
</script>
Enter fullscreen mode Exit fullscreen mode

HLS video playing in Ruby on Rails App
Voila, we have HLS video!


Closing Notes:

There's still a lot of work left to do for this to be anywhere near ready to go into a real application. Some of the biggest areas for improvements are adding frontend feedback on video upload progress & moving the video processing transcoding to a background job to free up the webserver resources.

Another major area for improvement is that I've implemented no error / exception handling. Definitely something that should be added in.

The actual m3u8 transcoding processes will also need to be setup as per your individual project's needs. In the example here, I transcoded the lowest quality version at a lower resolution to attempt to further cut down on bandwidth needs, but if you'd like to do something similar to youtube, where they have different resolutions ie 1080p, 720p, etc, it should be simple enough to create new arrays in the versions hash and name save the corresponding resolution to the variable.

If I do a pt. 2 on this tutorial the next thing I'd like to tackle is hosting the videos on AWS S3 and using Cloudfront as a CDN. This also opens up more possibilities in terms of restricting video access only to authenticated users via signed urls and signed cookies.

Discussion (5)

Collapse
phawk profile image
Pete Hawkins

Hi Adam, thanks for sharing, this is great!

I had looked at doing something similar a while back, I was doing some website work for my church who needed to upload weekly videos and have them transcoded to a couple of different resolutions for streaming.

I ran into a few issues and just thought I’d share them if they are in any way useful...

  • Using ffmeg on a CPU is quite slow compared to what some of the cloud services offer you, if the videos are large this might be an issue.
  • Using AWS transcode was great, fast and made all the formats for you, although was quite expensive per video for the clients needs. If I was building something for a businees I'd look at this, or the likes of dacast.com
  • I had issues streaming mp4 files from S3/cloudfront because of the size, I'm not sure if this was some kind of bug on my end, or if cloudfront doesn't like large files.
Collapse
adamkatora profile image
Adam Katora Author

Pete, thanks for taking the time to give this a read and sharing your feedback.

You bring up some good points in regards to transcoding speed & cloud services. The service I have on the docket to play around with next is AWS Elemental MediaConvert, AWS’s successor to eleastictranscoder that you mentioned. I believe MediaConvert’s pricing is cheaper than elastictranscoder, but even with cheaper pricing I've seen the costs run up pretty quickly with an internal tool I help to maintain that uses MediaConvert.

That being said, I have no baseline here for what you’d need to pay in terms of EC2 compute instances to get similar speed and performance to MediaConvert, so it’s possible those prices are better than I realize.

Collapse
phawk profile image
Pete Hawkins

Ah nice, I didn't even know there was a successor, that’s cool. Look forward to seeing if you do a follow up post using it!

On a side note, I have shifted some principles over the years in regards to video and even more recently images. Used to think thirdy party services where a waste of money and now I'm very grateful for what they provide.

Have been using imgix for image CDN / transformations on a couple projects now and wouldn't go back to doing that on my application servers. Even though the code side of it is pretty straightforward, imagemagick chewing through memory, or the weird edge cases you run into with certain file formats just isn't worth maintaining.

Collapse
fdocr profile image
Fernando

This is incredibly interesting and thorough, thanks for sharing Adam!

Collapse
adamkatora profile image
Adam Katora Author

Thanks Fernando, appreciate the kind words and you taking the time to give this a read!