Transcoding user-uploaded videos in Rails 7 with AWS Elastic Transcoder
Tags: rails, hotwire, aws
Published: 2022-07-11 22:52:11

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/z237xv7xr8zbgdb47ugc.png)

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](https://api.rubyonrails.org/classes/ActiveStorage/Blob.html#method-i-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](https://docs.aws.amazon.com/elastictranscoder/latest/developerguide/notifications.html), 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](https://medium.com/alturasoluciones/setting-up-rails-5-active-storage-with-amazon-s3-3d158cf021ff).

In AWS console, search for **Elastic Transcoder**.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/761n1ostliim79b08m0b.png)


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8erca4iibeax1na30o0v.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k1sy1dx9e64bhybyslu4.png)

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](https://github.com/rails/rails/issues/40333).
- 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.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n07a5ajcug3qfc97hs2z.png)

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.