Better File Uploads with Shrine: Processing

This article is part of the “Better File Uploads with Shrine” series.

Whenever we accept file uploads, we usually want to apply some processing to the files before storing them to permanent storage. We might want to

  • generate image thumbnails
  • optimize images
  • transcode videos
  • extract video screenshots

One approach is to process files on-the-fly, which is suitable for fast processing such as image resizing. However, longer running processing it’s generally better perform eagerly in a background job.

Each approach is suitable for certain requirements, and Shrine is the only file attachment library that supports both strategies. In this article we’ll talk about the latter – eager processing.

Image processing

Paperclip, CarrierWave, Dragonfly and Refile all ship with high-level helpers for image processing via ImageMagick. However, the concept of file processing isn’t actually specific to the context of accepting file uploads, it is a generic thing. So wouldn’t it be nice that, instead of each file attachment library reimplementing file processing over and over again, we just had a generic library which we could use with any file attachment library?

This is exactly what I did when I created Shrine. I extracted image processing logic from Refile::MiniMagick, and released a generic ImageProcessing library. It provides processing helper methods for ImageMagick (using MiniMagick) and libvips (using ruby-vips). Once ImageFlow gets released, I will add support for it as well.

require "image_processing/mini_magick"

# convert source.jpg -auto-orient -resize 600x600> -sharpen 0x1 output.jpg
thumbnail = ImageProcessing::MiniMagick
  .source(image)
  .convert("jpeg")
  .resize_to_limit!(600, 600)

thumbnail #=> #<Tempfile>

Eager processing

Generating and saving a set of processed files is provided by the derivatives Shrine plugin. We use it by defining a processor that returns processed files, and then trigger the creation at the desired time:

Shrine.plugin :derivatives
class ImageUploader < Shrine
  Attacher.derivatives do |original|
    magick = ImageProcessing::MiniMagick.source(original)

    {
      small:  magick.resize_to_limit!(300, 300),
      medium: magick.resize_to_limit!(500, 500),
      large:  magick.resize_to_limit!(800, 800),
    }
  end
end
class PhotosController < ApplicationController
  def create
    photo = Photo.new(photo_params)

    if photo.valid?
      photo.image_derivatives! # calls the processor
      photo.save
      # ...
    else
      # ...
    end
  end
end

In contrast to CarrierWave’s implicit class-level DSL or Paperclip’s hash-based declaration, with Shrine file processing is performed explicitly on the instance level, using plain Ruby. This gives you full control, allowing things like extracting processing into a service object and testing it in isolation, better optimizations, and ability to use any file processing tool you need.

Also, unlike CarrierWave and Paperclip, Shrine actually stores data about processed files into the database:

photo.image_data #=> 
# {
#   "id": "fed517.jpg",
#   "storage": "store",
#   "metadata": { ... },
#   "derivatives": {
#      "small": { "id": "586ef3.jpg", "storage": "store", "metadata": { ... } },
#      "medium": { "id": "0461d3.jpg", "storage": "store", "metadata": { ... } },
#      "large": { "id": "4f180c.jpg", "storage": "store", "metadata": { ... } },
#   }
# }

photo.image_derivatives #=>
# {
#   small: #<Shrine::UploadedFile id="586ef3.jpg" storage=:store ...>,
#   medium: #<Shrine::UploadedFile id="0461d3.jpg" storage=:store ...>,
#   large: #<Shrine::UploadedFile id="4f180c.jpg" storage=:store ...>,
# }

You can also trigger processing in a background job:

Shrine.plugin :backgrounding
Shrine::Attacher.promote_block { PromoteJob.perform_later(record, name, file_data) }
class PhotosController < ApplicationController
  def create
    photo = Photo.create(photo_params) # kicks off a background job
    # ...
  end
end
class PromoteJob < ActiveJob::Base
  def perform(record, name, file_data)
    attacher = Shrine::Attacher.retrieve(model: record, name: name, file: file_data)
    attacher.create_derivatives # call the processor and upload results
    attacher.atomic_promote
  end
end

Just to show that processing in Shrine isn’t in any way tied to images or the ImageProcessing gem, here is an example of processing videos using streamio-ffmpeg:

# Gemfile
gem "streamio-ffmpeg"
require "streamio-ffmpeg"

class VideoUploader < Shrine
  Attacher.derivatives do |original|
    transcoded = Tempfile.new ["transcoded", ".mp4"]
    screenshot = Tempfile.new ["screenshot", ".jpg"]

    movie = FFMPEG::Movie.new(original.path)
    movie.transcode(transcoded.path)
    movie.screenshot(screenshot.path)

    { transcoded: transcoded, screenshot: screenshot }
  end
end

External processing

Shrine’s flexibility allows you to easily delegate processing to other 3rd party services. As an example, we’ll show transcoding videos with Transloadit using the shrine-transloadit gem.

# Gemfile
gem "shrine-transloadit"
Shrine.storages = {
  cache: Shrine::Storage::S3.new(prefix: "cache", **s3_options),
  store: Shrine::Storage::S3.new(**s3_options),
}

Shrine.plugin :transloadit,
  auth: { key: "<TRANSLOADIT_KEY>", secret: "<TRANSLOADIT_SECRET>" },
  credentials: { cache: :s3_store, store: :s3_store }
class VideoUploader < TransloaditUploader
  Attacher.transloadit_processor do
    import = file.transloadit_import_step
    mp4    = transloadit_step "mp4",  "/video/encode", preset: "mp4",  use: import
    webm   = transloadit_step "webm", "/video/encode", preset: "webm", use: import
    ogv    = transloadit_step "ogv",  "/video/encode", preset: "ogv",  use: import
    export = store.transloadit_export_step use: [mp4, webm, ogv]

    assembly = transloadit.assembly(steps: [import, mp4, webm, ogv, export])
    assembly.create!
  end

  Attacher.transloadit_saver do |results|
    mp4  = store.transloadit_file(results["mp4"])
    webm = store.transloadit_file(results["webm"])
    ogv  = store.transloadit_file(results["ogv"])

    merge_derivatives(mp4: mp4, webm: webm, ogv: ogv) # save processed results
  end
end
class VideoController < ApplicationController
  def create
    video = Video.create(video_params)

    TranscodeJob.perform_later(video, :file, video.file_data)

    # ...
  end
end
class TranscodeJob < ActiveJob::Base
  def perform(video, name, file_data)
    attacher = Shrine::Attacher.retrieve(model: video, name: name, file: file_data)

    response = attacher.transloadit_process # calls processor
    response.reload_until_finished!

    attacher.transloadit_save(response["results"]) # calls saver
    attacher.atomic_persist
  rescue Shrine::AttachmentChanged, ActiveRecord::RecordNotFound
    attacher.destroy # destroy orphaned files
  end
end

The above will spawn a TranscodeJob when a video is attached, then in the background job it will call Transloadit, wait for processing to finish, then save the results. If in the meantime the attachment has changed or the record was deleted, we make sure to delete the processed files to not leave any orphan files in our storage.

Notice how the derivatives plugin allowed us to easily save files uploaded by Transloadit with Attacher#merge_derivatives. This way processed files are retrieved the same as if we did the processing ourselves, which enables our application to remain agnostic as to how the files were processed.

video.file_derivatives #=> 
# {
#   mp4:  #<Shrine::UploadedFile id="c8ed02.mp4" storage=:store ...>,
#   webm: #<Shrine::UploadedFile id="f426d8.webm" storage=:store ...>,
#   ogv:  #<Shrine::UploadedFile id="7a79d6.ogv" storage=:store ...>,
# }

Conclusion

Since my goal with Shrine was to create a file attachment library that works for everyone, I wanted to make sure that there aren’t any limits in ways that you can do file processing. We’ve seen how we can do processing ourselves, or easily delegate it to a 3rd party service. This makes Shrine a versatile tool for handling any type of file uploads.

Janko Marohnić

Janko Marohnić

A passionate Ruby backend developer who fell in love with Roda & Sequel, and told Rails “it’s not me, it’s you”. He enjoys working with JSON APIs and SQL databases, while prioritizing testing, and always tries to find the best library for the job. Creator of Shrine and test.vim.

comments powered by Disqus