On-demand thumbnails with Rails and rmagick

My, it’s been a while since I wrote a Rails tutorial. I’m going to come back from the land of esoteric JavaScript techniques for a minute and talk about something you might actually want to use in real life.

Many people, when writing an app that uses image uploads in some way, like to generate thumbnails for said images. They put together some list of sizes they need to generate and make sure the app creates these sizes when users upload files. Fine. But what if you want to change your site design and need some new sizes? You need to regenerate thumbnails for all those millions of images your users sent you.

To get around this problem, you can use on-demand thumbnail generation. This means that the thumbnails are generated when an HTTP request comes in for them, rather than when the original files are uploaded. If we put the required dimensions in the request URL, we can get Rails to generate thumbnails at any size whenever we want.

First, you’re going to need to decide on a URL scheme for your thumbnails. I’m going to keep it simple here and serve all my site images with URLS that look like so:

/images/thumbs/34/600x450.jpg

That points to image id 34, at 600px wide by 450px tall. Simple. You might want to set up a more fragmented directory structure if you’re going to be generating lots of images, something like

/images/thumbs/037/825/004/600x450.jpg

which would load image #37825004. We’re going to need a route in config/routes.rb to handle our simple URL, so we add the following:

map.thumbnail "/images/thumbs/:id/:dims.jpg",
    :controller => "images", :action => "serve_image"

The idea is that we generate the thumbnail and save it at the requested URL, so that future requests for the image will be handled by the webserver without invoking Rails.

So we’ve got a public interface for requesting images, now we need to set up the model to deal with the thumbnail process. We need a method that returns the URL for an image thumbnail, and a method for generating the thumbnail. It helps if your model includes an aspect_ratio field, which is generated from the original image on upload. Our model also has a full_path field, which says where the original image is stored in the filesystem.

class UploadedImage < ActiveRecord::Base

  # You can leave +height+ blank if you like.
  def thumb_path(w, h = nil)
    h ||= width / aspect_ratio
    "/images/thumbs/#{id}/#{w.to_i}x#{h.to_i}.jpg"
  end

  # Where to store images in the filesystem when they
  # are created.
  def image_path(w, h = nil)
    "#{RAILS_ROOT}/public#{thumb_path(w, h)}"
  end

  # Generate thumbnail from the original image
  def thumbnail!(w, h)
    ImageTools.thumbnail(full_path, image_path(w,h), w.to_i, h.to_i)
  end

end

For generating the thumbnails, we call out to a module that handles all our image-related needs, stored in lib/image_tools.rb. There are faster ways of generating thumbnails but I’ve had trouble with some of RMagick’s features on some servers. Do check out its documentation for more info.

require 'RMagick'
require 'fileutils'

module ImageTools
  class << self
    include Magick

    def thumbnail(source, target, width, height = nil)
      return nil unless File.file?(source)
      height ||= width

      img = Image.read(source).first
      rows, cols = img.rows, img.columns

      source_aspect = cols.to_f / rows
      target_aspect = width.to_f / height
      thumbnail_wider = target_aspect > source_aspect

      factor = thumbnail_wider ? width.to_f / cols : height.to_f / rows
      img.thumbnail!(factor)
      img.crop!(CenterGravity, width, height)

      FileUtils.mkdir_p(File.dirname(target))
      img.write(target) { self.quality = 75 }
    end

  end
end

So now we’ve got the model sorted out so it can provide URLs for thumbnails and generate the images, and we have a route set up for requesting images, but we need the bit in the middle: the controller. Remember, if our controller action is called, it means the image does not yet exist in the public directory and must be generated and saved there.

class ImagesController < ActionController::Base

  def serve_image
    @image = UploadedImage.find(params[:id])
    width, height = params[:dims].scan(/\d+/).map(&:to_i)
    @image.thumbnail!(width, height)
    redirect_to(@image.thumb_path(width, height))
  end

end

We just generate the thumbnail and then redirect to the thumbnail’s URL. This second request will be handled by Apache as the image file will now be sitting in the public directory. Now, all you have to do is put

<%= image_tag @image.thumb_path(600,450) %>

in your views and all your images will be generated as and when they are needed.







4 Responses to “On-demand thumbnails with Rails and rmagick”

Nice implementation. I love that it gives the developer the flexibility to change their mind about thumbnail sizes at any point. The only problem I see is that a malicious user could easily fill up your hard-drive by repeatedly requesting thumbnails with increasing dimensions. This is easily fixed by limiting the allowed dimensions in your controller. If someone requests an invalid thumbnail size, send them either an error message or the closest available thumbnail. Not a show-stopping problem, I just wanted to point that out before people start using the above code on their production servers.

Marcos Kuhns added these pithy words on Mar 24 08 at 5:56 pm

Thanks, Marcos. I did originally put in some size-checking code in the example but took it out as it obscured the main point somewhat. What I tend to do is write a quick rake task for flushing the thumbnail cache and run it as a housekeeping job. You could get your application do do this job intermittently as well.

James added these pithy words on Mar 24 08 at 7:01 pm

Hi,

This is a great article, and something that I’ve been searching for, for a while now.

However, you say …

”It helps if your model includes an aspect_ratio field, which is generated from the original image on upload”

… and I was just wondering how you achieve this ?

(I’m new to image processing, but it’s kinda key to an app I’m developing)

Thanks

-Mic

Mic Pringle added these pithy words on Apr 08 08 at 8:46 pm

Mic, first off what I tend to do is add a method for grabbing the dimensions of an image:

module ImageTools
  class < < self
    def dimensions(source)
      img = Image.read(source).first
      img.rows, img.columns
    end
  end
end

Then in your model, use a before_validation hook:

class MyModel < ActiveRecord::Base
  before_validation :set_dimensions!
  def set_dimensions!
    rows, cols = ImageTools.dimensions(self.file_path)
    self.width = cols
    self.height = rows
    self.aspect_ratio = cols.to_f / rows
  end
end

Hope that helps.

James added these pithy words on Apr 10 08 at 8:51 pm

Leave a Reply