Skip to content
Janko Marohnić edited this page Jan 18, 2020 · 3 revisions

This walkthrough shows how to add image cropping functionality to a Rails app. It assumes you have direct uploads set up as shown in Adding Direct App Uploads.

1. Derivatives

We'll be generating thumbnails, so let's add the ImageProcessing gem to the Gemfile:

# Gemfile
# ...
gem "image_processing", "~> 1.9"

We'll be using libvips for image processing, so make sure to have it installed:

$ brew install vips

Let's now generate image thumbnails and set them up as derivatives:

# config/initializers/shrine.rb
# ...
Shrine.plugin :derivatives
# app/uploaders/image_uploader.rb

require "image_processing/vips"

class ImageUploader < Shrine
  THUMBNAILS = {
    large:  [800, 800],
    medium: [600, 600],
    small:  [300, 300],
  }

  Attacher.derivatives do |original|
    vips = ImageProcessing::Vips.source(original)

    THUMBNAILS.transform_values do |(width, height)|
      vips.resize_to_limit!(width, height)
    end
  end
end
# app/controllers/photos_controller.rb

class PhotosController < ApplicationController
  # ...
  def create
    @photo = Photo.new(photo_params)

    if @photo.valid?
      @photo.image_derivatives! if @photo.image_changed? # create derivatives
      @photo.save
      # ...
    else
      # ...
    end
  end
  # ...
end

2. Cropping (frontend)

Now we'll give users the ability to crop uploaded images. We will show a crop box using Cropper.js, then on form submit crop points will be sent to the backend, which will then apply cropping during processing.

We'll start by installing Cropper.js:

$ yarn add cropperjs

We'll create a cropbox() function:

// app/javascript/cropbox.js

import 'cropperjs/dist/cropper.css'

import Cropper from 'cropperjs'

function cropbox(image, url, { onCrop }) {
  image.src = url

  new Cropper(image, {
    aspectRatio: 1,
    viewMode: 1,
    guides: false,
    autoCropArea: 1.0,
    background: false,
    zoomable: false,
    crop: event => onCrop(event.detail)
  })
}

export default cropbox

Then we'll call it after the upload finishes, and update the uploaded file data in the hidden field when crop points change:

// app/javascript/fileUpload.js
// ...
import cropbox from 'cropbox'
// ...
  uppy.on('upload-success', (file, response) => {
    // retrieve uploaded file data
    const uploadedFileData = response.body['data']

    // set hidden field value to the uploaded file data so that it's submitted
    // with the form as the attachment
    hiddenInput.value = JSON.stringify(uploadedFileData)

    cropbox(imagePreview, response.uploadURL, {
      onCrop(detail) {
        let fileData = JSON.parse(hiddenInput.value)
        fileData['metadata']['crop'] = detail
        hiddenInput.value = JSON.stringify(fileData)
      }
    })
  })
// ...

3. Cropping (backend)

In our uploader we can now read the crop points from the uploaded file and apply cropping before resizing:

# app/uploaders/image_uploader.rb

class ImageUploader < Shrine
  THUMBNAILS = {
    large:  [800, 800],
    medium: [600, 600],
    small:  [300, 300],
  }

  Attacher.derivatives do |original|
    vips = ImageProcessing::Vips.source(original)
    vips = vips.crop(*file.crop_points) # apply cropping

    THUMBNAILS.transform_values do |(width, height)|
      vips.resize_to_limit!(width, height)
    end
  end

  class UploadedFile
    # convenience method for fetching crop points from metadata
    def crop_points
      metadata.fetch("crop").fetch_values("x", "y", "width", "height")
    end
  end
end

Now you should have working image cropping.

4. Backgrounding

Let's now move image processing into a background job:

# config/initializers/shrine.rb
# ...
Shrine.plugin :backgrounding
Shrine::Attacher.promote_block { PromoteJob.perform_later(record, name, file_data) }
Shrine::Attacher.destroy_block { DestroyJob.perform_later(data) }
# app/jobs/promote_job.rb

class PromoteJob < ApplicationJob
  def perform(record, name, file_data)
    attacher = Shrine::Attacher.retrieve(model: record, name: name, file: file_data)
    attacher.create_derivatives
    attacher.atomic_promote
  end
end
# app/jobs/destroy_job.rb

class DestroyJob < ApplicationJob
  def perform(data)
    attacher = Shrine::Attacher.from_data(data)
    attacher.destroy
  end
end
# app/controllers/photos_controller.rb

class PhotosController < ApplicationController
  # ...
  def create
    @photo = Photo.new(photo_params)

    if @photo.valid?
      # we removed `photo.image_derivatives!` call here
      @photo.save
      # ...
    else
      # ...
    end
  end
  # ...
end

We'll add a fallback URL for missing derivatives which uses on-the-fly processing, so that the user sees the cropped image while the background job is still processing:

# config/initializers/shrine.rb
# ...
Shrine.plugin :derivation_endpoint, secret_key: Rails.application.secret_key_base
Shrine.plugin :default_url
# config/routes.rb

Rails.application.routes.draw do
  # ...
  mount ImageUploader.derivation_endpoint => "/derivations/image"
end
# app/uploaders/image_uploader.rb
# ...
class ImageUploader < Shrine
  # ...
  plugin :derivation_endpoint, prefix: "derivations/image"

  # Default URLs of missing derivatives to on-the-fly processing.
  Attacher.default_url do |derivative: nil, **|
    next unless derivative && file

    file.derivation_url :transform, shrine_class.urlsafe_serialize(
      crop:            file.crop_points,
      resize_to_limit: THUMBNAILS.fetch(derivative),
    )
  end

  # Generic derivation that applies a given sequence of transformations.
  derivation :transform do |file, transformations|
    transformations = shrine_class.urlsafe_deserialize(transformations)

    vips = ImageProcessing::Vips.source(file)
    vips.apply!(transformations)
  end
  # ...
end

See also