Ultra-Fast Thumbnail Generation with Jekyll and libvips

Speedometer at high speed

How we started: thumbnails with smartcropper

In the very early days of OpsLevel, our marketing website was powered by WordPress. Even though our site then was small, WordPress was a pretty big moving part that required more maintenance than it was worth. We found ourselves spending time on upgrading both WordPress and its plugins, debugging when things broke, and managing performance. We also found that drafts were not a great workflow for previewing or staging changes as the live production site wouldn’t always look the same as a draft edit.

In mid-2019, we hopped aboard the Jamstack train and rebuilt our site (this site you’re reading now) using Jekyll.

One feature we wanted for our blog posts was automatic thumbnail generation. Every blog post has a hero image (like the speedometer above) and we wanted smaller versions of this image for the blog index page.

We found Kari Silvennoinen’s blog post which describes a mechanism for using the smartcropper gem in a Jekyll generator.

We refactored things a bit for our use case (we generate multiple thumbnail sizes and also allow selectively overriding thumbnails). However, the main call to smartcropper was nearly identical to Kari’s:

def crop_and_scale(source_image_path, dest_image_path)
  SmartCropper.from_file(source_image_path).
    smart_crop_and_scale(@width, @height).
    write(dest_image_path)
end

At the time, this change increased the build time of our site slightly. Building the entire site would take about 10 seconds in devlocal and a few minutes to build on Netlify for production. Not terrible and hey, we’re generating thumbnails, so obviously that’s going to take some time.

Bigger Site, Slower Build

Fast forward two years and we now have a lot more blog posts, which meant more thumbnails to generate. During this period, the build time of our site creeped up to 30s - 40s for devlocal and 12+ minutes on Netlify. Developing or changing our site was painful. 95% of the time was spent generating thumbnails, so we investigated to see if there was a better way.

Under the covers, smartcropper uses RMagick, which is Ruby interface for ImageMagick.

After some research, we found jekyll_picture_tag, which is based on libvips. libvips bills itself as:

libvips is a demand-driven, horizontally threaded image processing library. Compared to similar libraries, libvips runs quickly and uses little memory.

That sounds promising. Let’s put it to the test.

libvips supports the same entropy-based cropping as smartcropper, so it was a straightforward replacement in our generator. Here’s our new implementation of crop_and_scale:

def crop_and_scale(source_image_path, dest_image_path)
  thumb = Vips::Image.thumbnail(source_image_path, @width, height: @height, crop: "entropy")
  thumb.write_to_file(dest_image_path)
end

libvips is fast

We profiled site build time with both smartcropper and libvips.

This is the output of jekyll build --profile:

smartcropper libvips
| PHASE      |    TIME |
+------------+---------+
| RESET      |  0.0002 |
| READ       |  0.2640 |
| GENERATE   |  0.0215 |
| RENDER     |  1.3476 |
| CLEANUP    |  0.0114 |
| WRITE      | 28.6394 |
+------------+---------+
| TOTAL TIME | 30.2841 |
| PHASE      |   TIME |
+------------+--------+
| RESET      | 0.0002 |
| READ       | 0.1468 |
| GENERATE   | 0.0228 |
| RENDER     | 0.5967 |
| CLEANUP    | 0.0118 |
| WRITE      | 2.5642 |
+------------+--------+
| TOTAL TIME | 3.3425 |

🤯

Generating thumbnails with libvips is nearly 10x faster than smartcropper.

Ten. Times. Faster.

Full site builds now take < 4 seconds in devlocal and < 60 seconds on Netlify for production. In addition, using Jekyll’s --incremental option in devlocal makes editing nearly instant.

The full implementation

Here’s our full implementation for generating thumbnails in Jekyll.

It’s pretty simple, but supports generating multiple thumbnails from a single image. It also supports overriding thumbnails with an explicit file.

Put the following in _plugins/post_thumbnail_generator.rb:

require 'vips'

module Jekyll
  class PostThumbnailImage < StaticFile
    def initialize(site:, base:, dir:, name:, suffix:, override:, width:, height:)
      @dest_dir = File.join("images", "thumbnail")
      @suffix = suffix
      @width = width
      @height = height
      @override = override

      if @override
        name = @override
      end

      super(site, base, dir, name)
    end

    def destination(dest)
      prefix = @name.delete_suffix(extname)
      name = [prefix, '_', @suffix, extname].join
      File.join(dest, @dest_dir, name)
    end

    def write(dest)
      dest_path = destination(dest)

      return false if File.exist?(dest_path) and !modified?
      StaticFile::mtimes[path] = mtime

      FileUtils.mkdir_p(File.dirname(dest_path))

      if @override
        FileUtils.cp(path, dest_path)
      else
        crop_and_scale(path, dest_path)
      end

      true
    end

    def crop_and_scale(path, dest_path)
      thumb = Vips::Image.thumbnail(path, @width, height: @height, crop: "entropy")
      thumb.write_to_file(dest_path)
    end
  end

  class PostThumbnailGenerator < Generator
    def generate(site)
      thumbnail_config = site.config['thumbnails']
      return unless thumbnail_config

      site.posts.docs.each do |post|
        if post.data.has_key?('thumbnail')

          thumbnail_config.each do |thumbnail_name, thumbnail_settings|
            w = thumbnail_settings['width']
            h = thumbnail_settings['height']

            # Only works if post image is in src/images/ folder.
            post_thumbnail_image =
              PostThumbnailImage.new(
                site: site,
                base: site.source,
                dir: "images",
                name: post.data.dig('thumbnail', 'src'),
                suffix: thumbnail_name,
                override: post.data.dig('thumbnail', 'override', thumbnail_name),
                width: w,
                height: h)
            site.static_files << post_thumbnail_image
          end
        end
      end
    end
  end
end

To specify the various thumbnail dimensions, add or customize the following in your _config.yml:

thumbnails:
  preview:
    width: 360
    height: 220
  sidebar:
    width: 80
    height: 80

In your posts, you can set thumbnail in the front matter. For example, here’s this post’s front matter:

thumbnail:
  src: /thumbnails/lightning-at-the-beach-360.jpg
  alt: "Lightning at the Beach"
  override: # <--- optional, but we specified it here
    preview: /thumbnails/lightning-at-the-beach-360.jpg
    sidebar: /thumbnails/lightning-at-the-beach-80.jpg

Check us out

If you’re interested in performance, microservices, or helping teams adopt DevOps and service ownership, check out our open roles.

Learn how to grow your microservice architecture without the chaos.

Not ready for a demo? Stay in the loop with our newsletter.