DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Vasa Fedorow
Vasa Fedorow

Posted on

Transcoding user-uploaded videos in Rails 7 with AWS Elastic Transcoder

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.

Image description

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'
Enter fullscreen mode Exit fullscreen mode

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',
)
Enter fullscreen mode Exit fullscreen mode

Second, let’s add a one-to-many relationship between records and files.

app/models/profile.rb

has_many_attached :slides
Enter fullscreen mode Exit fullscreen mode

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" %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Image description

Image description

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.

Image description

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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)

🌚 Life is too short to browse without dark mode