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.