I recently had the pleasure of helping someone I don’t know fix a problem they had with generating image variants using ActiveStorage::Variant
. They were using the libvips
processor (which I believe is the default image processor now in Rails 7 because it is faster and uses less memory), and wanted to turn off the sharpening that vips automatically applies when resizing images.
They understood how to use ImageProcessing
(the high level “wrapper” that ActiveStorage::Variant
uses to communicate to libvips
) to turn off sharpening, but were stuck when it came to disabling sharpening through ActiveStorage.
“I must be doing something wrong here, but I can’t tell how to properly do this from the docs. Help would be greatly appreciated.”
Unfortunately, the documentation available in the Rails Guides and api.rubyonrails.org was not detailed enough, and left out some key information. This person tried out a couple of things in an attempt to get things to work and when they weren’t successful, decided to reach out on a public forum.
Let’s put ourselves in this person’s shoes and see if we can retrace the steps they took. Say we have a model User
, which has an avatar image. With ActiveRecord
and ActiveStorage
, we set it up like this:
class User < ApplicationRecord
has_one_attached :avatar
end
Now, in our app view, we want to display a resized version of this avatar. There is where ActiveStorage::Variant
comes in. In our view, we do:
= image_tag user.avatar.variant(resize_to_fit: [100, 100])
We notice that the resized images are sharpened, which we don’t want. After reading some documentation, we realize that this is a default setting and can be turned off. We dig a bit deeper and find:
User#avatar
returns an instance of the class ActiveStorage::Blob::Representable
ActiveStorage::Blob::Representable#variant
takes in a hash called transformations
, which describes the transformations we want to apply to our image and returns an instance of the class ActiveStorage::Variant
ActiveStorage::Variant
relies on the ImageProcessing
gem, which itself relies on libvips
to do the actual image processingFrom the ImageProcessing
docs, it is clear that we can disable image sharpening by doing:
ImageProcessing::Vips
.source(image)
.resize_to_limit!(100, 100, sharpen: false)
However, the docs for ActiveStorage::Variant
make no mention of this and it is unclear how we can turn off sharpening from the “transformation” that we pass into #variant
. So, we try a couple of things:
= image_tag user.avatar.variant(resize_to_fit: [width, height], sharpen: false)
Nope, that doesn’t work. How about:
= image_tag user.avatar.variant(resize_to_fit: [width, height], options: { sharpen: false })
This, unfortunately, throws an error.
Finally, though it looks weird, we try this:
= image_tag user.avatar.variant(resize_to_fit: [width, height, sharpen: false])
And that works.
Why does this work? To find out, we’ll have to take a dive into the Rails codebase. The call to #variant
, via user.avatar.variant(...)
, ends up in a class called ImageProcessingTransformer
. This class is ultimately responsible for passing through the transformations that we send in to #variant
, to the ImageProcessing::Vips
class.
ImageProcessingTransformer
takes advantage of a method that the ImageProcessing
gem exposes called apply
. apply
takes in a hash of key-value pairs, where the key corresponds to a given transformation (e.g. resize_to_limit
) and the value corresponds to an array of arguments that the transformation method takes (e.g. width
, height
), destructures them to name/argument list pairs, and then uses Ruby’s public_send
method to call the transformation method with the given argument list. If I was to write a couple of lines of code trying to capture the gist of what apply
does, it would look something like:
# a hash that looks like this:
transformations = { resize_to_fit: [width, height, sharpen: false], rotate: [90, background: [0, 0, 0]] }
# is destructured into a name/argument list pair
transformations.inject(ImageProcessing::Vips.source(image)) do |chainable, transformation_method, arguments|
# which is then passed into `public_send`
# because `ImageProcessing` supports chaining methods together, we
# can apply all the transformations specified in the transformations hash,
# one by one
chainable.public_send(transformation_method, *arguments)
end
apply
is flexible enough to be able to destructure a hash or an array (e.g. [[:resize_to_limit, [400, 400]], [:strip, true]
), and also just pass in any object in as an argument (this is why we can do things like user.avatar.variant(rotate: 90)
).
Aside from the fact that we now know how to turn off sharpening or apply a different-than-default sharpening mask to a resize operation with ActiveStorage#variant
, it seems worth asking if there are any other takeaways from this journey.
For one, we know when calling #variant
that keys in the transformation hash refer to actual methods that the underlying image processor exposes (things like resize_to_fit
, crop
and so on), and the values in the transformation hash correspond directly to arguments that are sent in to the underlying image processor. We learned that a value in the transformation hash can either be an array of arguments, or a non-array object, like a number or boolean.
The other, arguably more important, lesson is that sometimes docs are not enough. When you need to make progress or get some work done and you’re stuck because the docs are deficient in some way, you’ll need to take steps to get yourself unstuck. We ran through a few in this article, like experimentation, reading the source, or like the person who asked the question about turning off sharpening, reaching out to humans (friends or strangers, IRL or online) for help. Then, when you figure it out, especially when you’re experimenting, try to dig deeper and understand why something worked (or didn’t work) the way it did.
If you got value out of this article and want to hear more from me, consider subscribing to my email newsletter to get notified whenever I publish a new article.