Migrating HLS videos to Mp4 format in Rails.
Purpose
Recently, I was tasked with migrating our HLS videos over to mp4 format and store it on S3 for a variety of reasons. I wanted to document the magic incantations I followed to make this happen.
The FFMPEG command.
The first step is how do you convert HLS to mp4. Well, theres a number of ways. FFMPEG is my go to solution because its widely available and generally on most servers.
To begin, I googled around and found this was the secret sauce to be able to convert HLS video into mp4.
ffmpeg -i <input> -acodec copy -bsf:a aac_adtstoasc -vcodec copy <output>
Whats super cool to note is <input>
can actually be a fully qualified https://provider/video.m3u8
url so the HLS manifest doesn’t have to be available locally.
Moving it to Rails
Okay, but how do we do that in Rails?
system("ffmpeg", "-i", hls_url, "-acodec", "copy", "-bsf:a", "aac_adtstoasc", "-vcodec", "copy", path)
You’ll notice I didn’t make this one big string. Instead the first argument is the command, and everything else afterwards are flags. The reason for this is to help prevent command injection.
Whats next
Alright, now that we have the command to convert the video, now we have to clean up some loose ends including generating a temporary file for storage, and then shoving the file contents into S3 via ActiveStorage.
Housekeeping
Since I know this method is going to do a couple things, lets call it migrate_to_mp4
. This method will also exist on the Video
model and the Video
will have one attached mp4 video like so:
class Video < ApplicationRecord
has_one_attached :mp4_video
def migrate_to_mp4
system("ffmpeg", "-i", hls_url, "-acodec", "copy", "-bsf:a", "aac_adtstoasc", "-vcodec", "copy", path)
end
end
Generating a temporary file
Now that we have some structure in place, lets work on expanding this method to actually work!
def migrate_to_mp4
tempfile = ::Tempfile.new(["video", ".mp4"])
path = tempfile.path
tempfile.close
tempfile.unlink
# hls_url is a db column on the video record.
system("ffmpeg", "-i", hls_url, "-acodec", "copy", "-bsf:a", "aac_adtstoasc", "-vcodec", "copy", path)
end
First we create a Tempfile
which has a number of semantics that make it great for creating…you guessed it, temporary files.
By passing ::Tempfile.new
an array we say: “Generate a temporary file with a random name prefixed with ‘video’ and ending with ‘.mp4’”
Next we save its path since its going to be unique. Then we close it and unlink it so it gets deleted immediately. We do this because if the file exists, FFMPEG will give us a warning and we have to manually address it which we dont want to have to do.
Finally, we pass the path along to the ffmpeg command and we’re nearly done!
Writing to ActiveStorage
The next step is to write this newly created file to ActiveStorage. To do so, we call the #attach
method on mp4_video
.
Like so:
mp4_video.attach(io: File.open(tempfile), filename: "video-#{id}.mp4")
Cleaning up
Okay we did it! Its done! Not quite, theres still a couple other loose ends to tie up. First, since we actually wrote this file onto disk, we should delete it. We should also wrap FFMPEG in a begin/ensure
clause to ensure we delete the file regardless of whether or not it succeeds.
Heres what our final method looks like:
def migrate_to_mp4
return if hls_url.blank?
tempfile = ::Tempfile.new(["video", ".mp4"])
path = tempfile.path
# We dont actually want the tempfile, just its path.
tempfile.close
tempfile.unlink
begin
system("ffmpeg", "-i", hls_url, "-acodec", "copy", "-bsf:a", "aac_adtstoasc", "-vcodec", "copy", path)
mp4_video.attach(io: File.open(tempfile), filename: "video-#{id}.mp4")
ensure
# always cleanup our mess.
File.delete(path)
end
end
Alright thats the final method I ended up with!
Closing thoughts
There are a couple extra steps as part of the migration process that I’ll add here. I also had to do the following:
1.) Find all videos not migrated 2.) If they’re not migrated, migrate them.
So this is easily broken up into 2 parts. The first part is writing the query to find all non-migrated videos. Heres what my query looked like:
scope :not_migrated,
-> {
left_joins(
:mp4_video_attachment,
).finished.where(active_storage_attachments: {id: nil})
}
Alright, that takes care of HOW to find not migrated videos. The next step is to do something about it.
When I find I need to do imperative items like this, I like to reach for ActiveJob
. We also use Sidekiq so its worth noting to make sure to use JSON serializable parameters with Sidekiq.
Heres what my job to migrate looked like:
class MigrateVideoStorageJob < ApplicationJob
queue_as :default
def perform(video_id=nil)
if video_id.blank?
ids = Video.not_migrated.ids
ids.each { |id| MigrateVideoStorageJob.perform_later(id) }
return
end
video = Video.find(video_id)
return if video.mp4_video.attached?
video.migrate_to_mp4
end
end
So then, in a console you can do the following:
bundle exec rails console
MigrateVideoStorageJob.perform_later
Now there are some issues with this job.
The first issue is that it goes 1 by 1 which means for every video we’re going to incur a full DB query.
Its not great, but there was only roughly 100 videos to migrate so I didn’t think it was worth batching and worrying about performance.
“Real artists ship”.
Yes. We’re done.
Anyways, this was my foray into migrating HLS videos over to MP4 videos. Thanks for coming along for the ride!