I’ve recently been tasked with handling user-uploaded videos and making sure they are playable on all browsers and devices.
Currently, iPhones default to shoot video in H.265 video format. As you can see below, Safari is the only browser that currently supports this codec.
This can be an issue if your website handles user-uploaded videos.
For this example, users are allowed to upload videos (Let’s call them slides) to their profiles. We want to inform users that their video is being processed, and we want to update them live when it’s done.
Stack:
- Rails 7
- Active Storage
- AWS S3 and Elastic Transcoder
- Hotwire / Turbo
- Sidekiq + Redis
First, let’s add the AWS SDK to our Gemfile:
gem 'aws-sdk-rails'
# OR
gem 'aws-sdk-elastictranscoder'
gem 'aws-sdk-s3'
Run bundle install
Create an initializer and update the credentials with your own.
app/config/intializers/aws-sdk.rb
Aws.config.update(
credentials: Aws::Credentials.new(Rails.application.credentials.aws[:access_key_id], Rails.application.credentials.aws[:secret_access_key]),
region: 'YOUR BUCKET REGION',
)
Second, let’s add a one-to-many relationship between records and files.
app/models/profile.rb
has_many_attached :slides
We want to be able to keep track of the transcoding status; one way we can do this is by adding a new column to active_storage_blobs
Let’s create a new migration:
rails g migration add_transcoding_status_to_active_storage_blobs transcoding_status:string
Run the migration:
rails db:migrate
Uploading files will be done in app/views/profiles/edit.html.erb
I suggest using something like Dropzone for handling multiple uploads. If you’d like to be able to preview videos before uploading, Dropzone doesn’t currently support that. However, you can grab the signed blob id which is returned in the event after a direct upload is completed and generate a URL. You can then use this URL as the video source on a video element.
To keep things short, I’ll just use a file field inside the profile form.
<%= f.file_field :slides,
direct_upload: true,
multiple: true,
accept: "image/png,image/jpg,image/jpeg,video/mp4,video/mov,video/avi,video/webm" %>
After the user submits the form, we want to check if there were any videos uploaded and whether the video has already been transcoded.
To do this, we can create a new method and utilise the before_validation callback.
app/models/profile.rb
before_validation :set_video_slide_transcode_status
private
def set_video_slide_transcode_status
return unless self.slides.any?
self.slides.each do |slide|
if slide.video? && slide.blob.transcoding_status.nil?
slide.blob.transcoding_status = 'pending'
end
end
end
To keep status names consistent, let’s use the same ones used by Elastic Transcoder, with the addition of pending
.
If you don’t already have ActiveStorage configured with S3, do that first before continuing. You can follow a tutorial like this.
In AWS console, search for Elastic Transcoder.
Ensure you set the output bucket the same as the input bucket. You can either create a new bucket or use the same one for thumbnails. Remember to take note of the pipeline_id after it’s been created.
If you would like to put a watermark on your transcoded videos, you can go into presets and click “Create New Preset”. Alternatively, you can use a default preset. I’ll be using the System preset: Generic 1080p. Take note of the preset id as well.
We want to set up a worker so that we can asynchronously perform the transcoding task. Assuming you already have sidekiq installed, we can run:
rails g sidekiq:worker TranscodeVideoSlidesWorker
app/workers/transcode_video_slides_worker.rb
class TranscodeVideoSlidesWorker
include Sidekiq::Worker
sidekiq_options retry: false
def perform(profile_id)
profile = Profile.find(profile_id)
pipeline_id = 'PIPELINE_ID HERE'
preset_id = 'PRESET_ID HERE'
region = 'YOUR BUCKET REGION HERE'
bucket = 'YOUR BUCKET NAME HERE'
transcoder_client = Aws::ElasticTranscoder::Client.new(region: region)
s3 = Aws::S3::Client.new
profile.slides.reverse_each do |slide|
begin
if slide.video? && slide.blob.transcoding_status == 'pending'
# Call profile reload, or profile will attach previously created blob if there is more than one video being transcoded
profile.reload
slide.blob.update_attribute(:transcoding_status, "progressing")
old_blob_id = slide.blob.id
input_key = slide.blob.key
input = {
key: input_key
}
# Create a new key which will be used as the new transcoded video blob key
new_key = ActiveStorage::Blob.generate_unique_secure_token
output = {
key: new_key,
preset_id: preset_id
}
job = transcoder_client.create_job(
pipeline_id: pipeline_id,
input: input,
outputs: [ output ]
)[:job][:id]
transcoder_client.wait_until(:job_complete, id: job)
# Convert response into object
response = OpenStruct.new(transcoder_client.read_job(id: job))
# If Job succeeds
if %w[warning complete].include? response.job.status.downcase!
path = "tmp/video-#{SecureRandom.alphanumeric(12)}.mp4"
# Temporarily download the transcoded video file to get the checksum, make sure to delete this file after
temp = s3.get_object(response_target: path, bucket: bucket, key: new_key)
#Get newly transcoded video checksum
checksum = Digest::MD5.file(path).base64digest
# Create new blob which will be attached to the profile and replace the old video soon
blob = ActiveStorage::Blob.create_before_direct_upload!(
filename: File.basename(new_key),
key: new_key,
content_type: "video/mp4",
byte_size: File.size(path),
checksum: checksum
)
blob.update_attribute(:transcoding_status, "complete")
# Delete old video
slide.purge
# Added skip_validation_setter column to profiles
# Skip validation setter is just an attribute to skip running validations
# https://github.com/rails/rails/issues/40333
profile.skip_validation_setter = "##{SecureRandom.hex(3)}"
profile.slides.attach(blob)
profile.save!(validate: false)
# Delete transcoded temporary file which was used to retrieve the checksum
File.delete(path)
# Turbo stream the new transcoded video to the user
Turbo::StreamsChannel.broadcast_replace_to "user_#{profile.user.id}", target: "blob_#{old_blob_id}", partial: 'profiles/slide', locals: { slide: profile.slides.find { |s| s.blob_id == blob.id }, profile: profile }
else
slide.blob.update_attribute(:transcoding_status, "error")
end
end
rescue Aws::Waiters::Errors::FailureStateError => e
slide.blob.update_attribute(:transcoding_status, "error")
# Update the user that the video couldn't be transcoded
Turbo::StreamsChannel.broadcast_replace_to "user_#{profile.user.id}", target: "blob_#{slide.blob.id}", partial: 'profiles/slide', locals: { slide: slide, profile: profile }
# Update the slide blob to error that caused the exception, then run the next iteration in the loop with next
next
rescue => e
slide.blob.update_attribute(:transcoding_status, "error")
next
end
end
end
end
What’s happening:
- First, we iterate through each video in reverse order so that when we later reattach the transcoded videos, they will be in the same order the user uploaded them.
- We then check if the status of the blob is pending which would have been set by our
set_video_slide_transcode_status
method. - A new transcoding job is created, and we poll to check the progress using the
wait_until
method which is provided by AWS SDK. - If the job succeeds, we download it to our temp folder so that we can retrieve the checksum. (Unfortunately, AWS doesn’t allow us to retrieve this)
- Create a new blob with the new key we set for the transcoded video.
- Delete the old video, and attach the new blob we just created to the profile. I added a column called
skip_validation_setter
and set it to a random string. If we don't do this,validate: false
is ignored and the video gets revalidated. This is mentioned here. - Delete the transcoded video from the temp folder.
- Turbo stream the transcoded video to the user.
Now that we have the worker setup. Let’s call it after the uploaded videos have been validated and after the update has been committed.
app/models/profile.rb
after_update_commit :transcode_video_slides
private
def transcode_video_slides
return unless slides.attached?
TranscodeVideoSlidesWorker.perform_async(self.id) if self.slides.detect { |slide| slide.video? && slide.blob.transcoding_status == "pending" }
end
We want the user to be informed that their video is being processed.
app/views/profiles/edit.html.erb
<% if profile.slides.attached? %>
<%= render partial: 'profiles/slide', collection: profile.slides, as: :slide, locals: { profile: profile } %>
<% end %>
app/views/profiles/_slide.html.erb
<li id="<%= dom_id(slide.blob) %>">
<div>
<% begin %>
<% if slide.image? %>
<% if slide.representable? %>
<%= image_tag slide %>
<% end %>
<% elsif slide.video? %>
<% if slide.representable? %>
<%= content_tag :video,
id: dom_id(slide),
playsinline: true,
controls: true do %>
<%= tag :source, src: slide, type: slide.content_type %>
<% end %>
<% end %>
<% end %>
<%# Add a rescue clause to handle non previewable files %>
<% rescue => e %>
<div>
<div>
Error
</div>
</div>
<% end %>
<% case %>
<% when %w[pending progressing].include?(slide.blob.transcoding_status) %>
<div>
<small>
<i class="fa-solid fa-spinner fa-spin me-1"></i>
</small>
<span>Processing</span>
</div>
<% when slide.blob.transcoding_status == "error" %>
<div>
<div>
<i class="fa-solid fa-circle-exclamation me-1"></i>
</div>
<span>Failed</span>
</div>
<% end %>
</div>
</li>
You can display a video thumbnail generated by ffmpeg using the preview method and add an animated spinner using font awesome to display that the video is being processed.
After the video is successfully transcoded, turbo stream will find the element by the blob id and replace it with the new blob.
I probably forgot to mention a few things, and the code can certainly be improved. For me, this was a pretty good solution and worked well in my particular environment. Hope this helps someone out.
Top comments (0)