Shrine Gem Ruby on Rails Serverless Image Handler, Background Job

726 Views Asked by At

I'm using the Shrine Gem to process files in my Ruby on Rails project using S3 multipart upload. I want to use the Derivatives plugin to point to a AWS Lamdba serverless image handler as my derivative remote_url, and process these derivatives in a background job. I've just about got this working, with one issue:

Directly after uploading a file, that record is saved with the Shrine cache storage url. Once the background job finishes processing and the derivatives are created, the promote job updates the attachment_url with the store endpoint.

So, what I want to do is promote the original URL to the store bucket first (so that default_url will point to the store bucket, not cache), then process the derivatives after this. However, I can't quite figure out how to do this. My Shrine initializer and uploader are below.

# config/initializers/shrine.rb

require 'shrine'
require 'shrine/storage/s3'
# require "shrine/storage/file_system"

s3_options = Rails.application.credentials.s3

Shrine.storages = {
  cache: Shrine::Storage::S3.new(prefix: 'cache', public: true, **s3_options),
  store: Shrine::Storage::S3.new(prefix: 'media', public: true, **s3_options),
}

Shrine.plugin :activerecord           # Load Active Record integration
Shrine.plugin :cached_attachment_data # For retaining cached file on form redisplays
Shrine.plugin :determine_mime_type
Shrine.plugin :infer_extension
Shrine.plugin :instrumentation
Shrine.plugin :pretty_location
Shrine.plugin :remote_url, max_size: 40*1024*1024 # ~40mb
Shrine.plugin :restore_cached_data    # Refresh metadata for cached files
Shrine.plugin :type_predicates
Shrine.plugin :uppy_s3_multipart      # Enable S3 multipart upload for Uppy https://github.com/janko/uppy-s3_multipart
Shrine.plugin :url_options, store: { host: Rails.application.credentials.asset_host } if Rails.application.credentials.asset_host.present?

# app/uploaders/file_uploader.rb

class FileUploader < Shrine
  plugin :derivatives
  plugin :default_url
  plugin :backgrounding

  # TODO: images returned by Shrine.remote_url have file extension set as .jpeg, not .jpg, which is annoying
  # TODO: set up URL fallbacks for backgrounded derivatives? https://shrinerb.com/docs/processing#url-fallbacks

  # The Cloudfront URL generated by the serverless image handler
  SERVERLESS_IMAGE_HOST = Rails.application.credentials.image_handler_host
  DEFAULT_EDITS = {
    rotate: 'auto',
    quality: 60,
    progressive: true,
    chromaSubsampling: '4:4:4',
    withoutEnlargement: true,
    sharpen: true
  }

  # Fall back to the original file URL when the derivative
  # https://shrinerb.com/docs/processing#url-fallbacks
  Attacher.default_url do |derivative: nil, **|
    file&.url if derivative
  end

  # Perform derivative transformations inside a background job
  Attacher.promote_block do |**options|
    if file.image?
      # TODO: the initially promoted file URL is saved to the record as the cache URL. We need to
      # promote the original image first, then perform the derivative job.
      # promote
      AttachmentDerivativeJob.perform_later(self.class.name, record.class.name, record.id, name, file_data)
    else
      promote
    end
  end

  # The derivatives plugin allows storing processed files ("derivatives") alongside the main attached file
  # https://shrinerb.com/docs/plugins/derivatives
  Attacher.derivatives do |original|
    def serverless_image_request(edits = {})
      request_path = Base64.strict_encode64({
        bucket: Shrine.storages[:cache].bucket.name,
        key: [Shrine.storages[:cache].prefix, record.attachment.id].reject(&:blank?).join('/'), # The aws object key of the original image in the `store` S3 bucket
        edits: edits
      }.to_json).chomp
      "#{SERVERLESS_IMAGE_HOST}/#{request_path}"
    end

    if file.image?
      process_derivatives(:image, original)
    else
      process_derivatives(:file, original)
    end
  end

  Attacher.derivatives :image do |original|
    {
      thumb: Shrine.remote_url( serverless_image_request({
        resize: {
          width: 200,
          height: 200,
          fit: 'cover'
        }.reverse_merge(DEFAULT_EDITS)
      })),
      small: Shrine.remote_url( serverless_image_request({
        resize: {
          width: 600,
          height: 600,
          fit: 'inside'
        }.reverse_merge(DEFAULT_EDITS)
      })),
      medium: Shrine.remote_url( serverless_image_request({
        resize: {
          width: 1200,
          height: 1200,
          fit: 'inside'
        }.reverse_merge(DEFAULT_EDITS)
      })),
      large: Shrine.remote_url( serverless_image_request({
        resize: {
          width: 2200,
          height: 2200,
          fit: 'inside'
        }.reverse_merge(DEFAULT_EDITS)
      }))
    }
  end

  Attacher.derivatives :file do |original|
    {}
  end
end
1

There are 1 best solutions below

0
On

An update - I was able to configure my Uploader class in such a way that it performs how I wanted by overriding the activerecord_after_save method in the Attacher class. Not sure if this is the best solution, but it seems to behave how I expect.

Now, when I upload a series of images they are uploaded directly to S3 and the original file is promoted to permanent Shrine store. In the after save callback, I trigger the AttachmentDerivativeJob, which handles processing of derivatives using AWS Lambda's default serverless image handler.

class FileUploader < Shrine
  plugin :derivatives
  plugin :default_url

  # The Cloudfront URL generated by the serverless image handler
  SERVERLESS_IMAGE_HOST = Rails.application.credentials.image_handler_host
  DEFAULT_EDITS = {
    rotate: 'auto',
    quality: 60,
    progressive: true,
    chromaSubsampling: '4:4:4',
    withoutEnlargement: true,
    sharpen: true
  }

  # Active Record - Overriding callbacks
  # https://shrinerb.com/docs/plugins/activerecord#overriding-callbacks
  class Attacher
    private

    def activerecord_after_save
      super

      if file.image? && derivatives.blank?
        AttachmentDerivativeJob.perform_later(self.class.name, self.record.class.name, self.record.id, self.name, self.file_data)
      end
    end
  end

  # Fall back to the original file URL when the derivative
  # https://shrinerb.com/docs/processing#url-fallbacks
  Attacher.default_url do |derivative: nil, **|
    file&.url if derivative
  end

  # The derivatives plugin allows storing processed files ("derivatives") alongside the main attached file
  # https://shrinerb.com/docs/plugins/derivatives
  Attacher.derivatives do |original|
    def serverless_image_request(edits = {})
      request_path = Base64.strict_encode64({
        bucket: Shrine.storages[:store].bucket.name,
        key: [Shrine.storages[:store].prefix, record.attachment.id].reject(&:blank?).join('/'), # The aws object key of the original image in the `store` S3 bucket
        edits: edits
      }.to_json).chomp
      "#{SERVERLESS_IMAGE_HOST}/#{request_path}"
    end

    if file.image?
      process_derivatives(:image, original)
    else
      process_derivatives(:file, original)
    end
  end

  Attacher.derivatives :image do |original|
    {
      thumb: Shrine.remote_url( serverless_image_request({
        resize: {
          width: 200,
          height: 200,
          fit: 'cover'
        }.reverse_merge(DEFAULT_EDITS)
      })),
      small: Shrine.remote_url( serverless_image_request({
        resize: {
          width: 600,
          height: 600,
          fit: 'inside'
        }.reverse_merge(DEFAULT_EDITS)
      })),
      medium: Shrine.remote_url( serverless_image_request({
        resize: {
          width: 1200,
          height: 1200,
          fit: 'inside'
        }.reverse_merge(DEFAULT_EDITS)
      })),
      large: Shrine.remote_url( serverless_image_request({
        resize: {
          width: 2200,
          height: 2200,
          fit: 'inside'
        }.reverse_merge(DEFAULT_EDITS)
      }))
    }
  end

  Attacher.derivatives :file do |original|
    {}
  end
end