Use ImageProcessing gem for ActiveStorage variants

ImageProcessing gem is a wrapper around MiniMagick and ruby-vips, and
implements an interface for common image resizing and processing. This
is the canonical image processing gem recommended in [Shrine], and
that's where it developed from. The initial implementation was extracted
from Refile, which also implements on-the-fly transformations.

Some features that ImageProcessing gem adds on top of MiniMagick:

  * resizing macros
    - #resize_to_limit
    - #resize_to_fit
    - #resize_to_fill
    - #resize_and_pad
  * automatic orientation
  * automatic thumbnail sharpening
  * avoids the complex and inefficient MiniMagick::Image class
  * will use "magick" instead of "convert" on ImageMagick 7

However, the biggest feature of the ImageProcessing gem is that it has
an alternative implementation that uses libvips. Libvips is an
alternative to ImageMagick that can process images very rapidly (we've
seen up 10x faster than ImageMagick).

What's great is that the ImageProcessing gem provides the same interface
for both implementations. The macros are named the same, and the libvips
implementation does auto orientation and thumbnail sharpening as well;
only the operations/options specific to ImageMagick/libvips differ. The
integration provided by this PR should work for both implementations.

The plan is to introduce the ImageProcessing backend in Rails 6.0 as the
default backend and deprecate the MiniMagick backend, then in Rails 6.1
remove the MiniMagick backend.
This commit is contained in:
Janko Marohnić 2018-04-06 01:48:29 +02:00
parent 662ba236d1
commit ca12968587
No known key found for this signature in database
GPG Key ID: 84166B4FB1C84F3E
13 changed files with 156 additions and 81 deletions

View File

@ -88,7 +88,7 @@ group :storage do
gem "google-cloud-storage", "~> 1.8", require: false
gem "azure-storage", require: false
gem "mini_magick"
gem "image_processing", "~> 1.2"
end
group :ujs do

View File

@ -229,10 +229,10 @@ GEM
faye-websocket (0.10.7)
eventmachine (>= 0.12.0)
websocket-driver (>= 0.5.1)
ffi (1.9.18)
ffi (1.9.18-java)
ffi (1.9.18-x64-mingw32)
ffi (1.9.18-x86-mingw32)
ffi (1.9.23)
ffi (1.9.23-java)
ffi (1.9.23-x64-mingw32)
ffi (1.9.23-x86-mingw32)
globalid (0.4.1)
activesupport (>= 4.2.0)
google-api-client (0.17.3)
@ -265,6 +265,9 @@ GEM
httpclient (2.8.3)
i18n (1.0.0)
concurrent-ruby (~> 1.0)
image_processing (1.2.0)
mini_magick (~> 4.0)
ruby-vips (>= 2.0.10, < 3)
io-like (0.3.0)
jdbc-mysql (5.1.44)
jdbc-postgres (9.4.1206)
@ -393,6 +396,8 @@ GEM
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-progressbar (1.9.0)
ruby-vips (2.0.10)
ffi (~> 1.9)
ruby_dep (1.5.0)
rubyzip (1.2.1)
rufus-scheduler (3.4.2)
@ -515,11 +520,11 @@ DEPENDENCIES
delayed_job_active_record
google-cloud-storage (~> 1.8)
hiredis
image_processing (~> 1.2)
json (>= 2.0.0)
kindlerb (~> 1.2.0)
libxml-ruby
listen (>= 3.0.5, < 3.2)
mini_magick
minitest-bisect
mocha
mysql2 (>= 0.4.10)

View File

@ -4,7 +4,7 @@ Active Storage makes it simple to upload and reference files in cloud services l
Files can be uploaded from the server to the cloud or directly from the client to the cloud.
Image files can furthermore be transformed using on-demand variants for quality, aspect ratio, size, or any other [MiniMagick](https://github.com/minimagick/minimagick) supported transformation.
Image files can furthermore be transformed using on-demand variants for quality, aspect ratio, size, or any other [MiniMagick](https://github.com/minimagick/minimagick) or [Vips](http://www.rubydoc.info/gems/ruby-vips/Vips/Image) supported transformation.
## Compared to other storage solutions

View File

@ -6,8 +6,18 @@ require "active_storage/downloading"
# These variants are used to create thumbnails, fixed-size avatars, or any other derivative image from the
# original.
#
# Variants rely on {MiniMagick}[https://github.com/minimagick/minimagick] for the actual transformations
# of the file, so you must add <tt>gem "mini_magick"</tt> to your Gemfile if you wish to use variants.
# Variants rely on {ImageProcessing}[https://github.com/janko-m/image_processing] gem for the actual transformations
# of the file, so you must add <tt>gem "image_processing"</tt> to your Gemfile if you wish to use variants. By
# default, images will be processed with {ImageMagick}[http://imagemagick.org] using the
# {MiniMagick}[https://github.com/minimagick/minimagick] gem, but you can also switch to the
# {libvips}[http://jcupitt.github.io/libvips/] processor operated by the {ruby-vips}[https://github.com/jcupitt/ruby-vips]
# gem).
#
# Rails.application.config.active_storage.processor
# # => :mini_magick
#
# Rails.application.config.active_storage.processor = :vips
# # => :vips
#
# Note that to create a variant it's necessary to download the entire blob file from the service and load it
# into memory. The larger the image, the more memory is used. Because of this process, you also want to be
@ -18,7 +28,7 @@ require "active_storage/downloading"
# To refer to such a delayed on-demand variant, simply link to the variant through the resolved route provided
# by Active Storage like so:
#
# <%= image_tag Current.user.avatar.variant(resize: "100x100") %>
# <%= image_tag Current.user.avatar.variant(resize_to_fit: [100, 100]) %>
#
# This will create a URL for that specific blob with that specific variant, which the ActiveStorage::RepresentationsController
# can then produce on-demand.
@ -27,15 +37,22 @@ require "active_storage/downloading"
# has already been processed and uploaded to the service, and, if so, just return that. Otherwise it will perform
# the transformations, upload the variant to the service, and return itself again. Example:
#
# avatar.variant(resize: "100x100").processed.service_url
# avatar.variant(resize_to_fit: [100, 100]).processed.service_url
#
# This will create and process a variant of the avatar blob that's constrained to a height and width of 100.
# Then it'll upload said variant to the service according to a derivative key of the blob and the transformations.
#
# A list of all possible transformations is available at https://www.imagemagick.org/script/mogrify.php. You can
# combine as many as you like freely:
# Variant options are forwarded directly to the ImageProcessing gem. Visit the following links for a list of
# available ImageProcessing commands and processor operations:
#
# avatar.variant(resize: "100x100", monochrome: true, flip: "-90")
# * {ImageProcessing::MiniMagick}[https://github.com/janko-m/image_processing/blob/master/doc/minimagick.md#methods]
# * {ImageMagick reference}[https://www.imagemagick.org/script/mogrify.php]
# * {ImageProcessing::Vips}[https://github.com/janko-m/image_processing/blob/master/doc/vips.md#methods]
# * {ruby-vips reference}[http://www.rubydoc.info/gems/ruby-vips/Vips/Image]
#
# You can combine as many of these options as you like freely:
#
# avatar.variant(resize_to_fit: [100, 100], monochrome: true, flip: "-90")
class ActiveStorage::Variant
include ActiveStorage::Downloading
@ -82,10 +99,10 @@ class ActiveStorage::Variant
end
def process
open_image do |image|
transform image
format image
upload image
download_blob_to_tempfile do |image|
variant = transform image
upload variant
variant.close!
end
end
@ -102,31 +119,12 @@ class ActiveStorage::Variant
blob.content_type.presence_in(WEB_IMAGE_CONTENT_TYPES) || "image/png"
end
def open_image(&block)
image = download_image
begin
yield image
ensure
image.destroy!
end
end
def download_image
require "mini_magick"
MiniMagick::Image.create(blob.filename.extension_with_delimiter) { |file| download_blob_to(file) }
end
def transform(image)
variation.transform(image)
format = "png" unless WEB_IMAGE_CONTENT_TYPES.include?(blob.content_type)
variation.transform(image, format: format)
end
def format(image)
image.format("PNG") unless WEB_IMAGE_CONTENT_TYPES.include?(blob.content_type)
end
def upload(image)
File.open(image.path, "r") { |file| service.upload(key, file) }
def upload(file)
service.upload(key, file)
end
end

View File

@ -8,15 +8,7 @@
#
# ActiveStorage::Variation.new(resize: "100x100", monochrome: true, trim: true, rotate: "-90")
#
# You can also combine multiple transformations in one step, e.g. for center-weighted cropping:
#
# ActiveStorage::Variation.new(combine_options: {
# resize: "100x100^",
# gravity: "center",
# crop: "100x100+0+0",
# })
#
# A list of all possible transformations is available at https://www.imagemagick.org/script/mogrify.php.
# The options map directly to {ImageProcessing}[https://github.com/janko-m/image_processing] commands.
class ActiveStorage::Variation
attr_reader :transformations
@ -51,10 +43,49 @@ class ActiveStorage::Variation
@transformations = transformations
end
# Accepts an open MiniMagick image instance, like what's returned by <tt>MiniMagick::Image.read(io)</tt>,
# and performs the +transformations+ against it. The transformed image instance is then returned.
def transform(image)
# Accepts a File object, performs the +transformations+ against it, and
# saves the transformed image into a temporary file. If +format+ is specified
# it will be the format of the result image, otherwise the result image
# retains the source format.
def transform(file, format: nil)
ActiveSupport::Notifications.instrument("transform.active_storage") do
if processor
image_processing_transform(file, format)
else
mini_magick_transform(file, format)
end
end
end
# Returns a signed key for all the +transformations+ that this variation was instantiated with.
def key
self.class.encode(transformations)
end
private
# Applies image transformations using the ImageProcessing gem.
def image_processing_transform(file, format)
operations = transformations.inject([]) do |list, (name, argument)|
if name.to_s == "combine_options"
ActiveSupport::Deprecation.warn("The ImageProcessing ActiveStorage variant backend doesn't need :combine_options, as it already generates a single MiniMagick command. In Rails 6.1 :combine_options will not be supported anymore.")
list.concat argument.to_a
else
list << [name, argument]
end
end
processor
.source(file)
.loader(page: 0)
.convert(format)
.apply(operations)
.call
end
# Applies image transformations using the MiniMagick gem.
def mini_magick_transform(file, format)
image = MiniMagick::Image.new(file.path, file)
transformations.each do |name, argument_or_subtransformations|
image.mogrify do |command|
if name.to_s == "combine_options"
@ -66,15 +97,20 @@ class ActiveStorage::Variation
end
end
end
image.format(format) if format
image.tempfile.tap(&:open)
end
end
# Returns a signed key for all the +transformations+ that this variation was instantiated with.
def key
self.class.encode(transformations)
end
# Returns the ImageProcessing processor class specified by `ActiveStorage.processor`.
def processor
require "image_processing"
ImageProcessing.const_get(ActiveStorage.processor.to_s.camelize) if ActiveStorage.processor
rescue LoadError
ActiveSupport::Deprecation.warn("Using mini_magick gem directly is deprecated and will be removed in Rails 6.1. Please add `gem 'image_processing', '~> 1.2'` to your Gemfile.")
end
private
def pass_transform_argument(command, method, argument)
if eligible_argument?(argument)
command.public_send(method, argument)

View File

@ -45,6 +45,7 @@ module ActiveStorage
mattr_accessor :queue
mattr_accessor :previewers, default: []
mattr_accessor :analyzers, default: []
mattr_accessor :processor, default: :mini_magick
mattr_accessor :paths, default: {}
mattr_accessor :variable_content_types, default: []
mattr_accessor :content_types_to_serve_as_binary, default: []

View File

@ -45,6 +45,7 @@ module ActiveStorage
config.after_initialize do |app|
ActiveStorage.logger = app.config.active_storage.logger || Rails.logger
ActiveStorage.queue = app.config.active_storage.queue
ActiveStorage.processor = app.config.active_storage.processor || :mini_magick
ActiveStorage.previewers = app.config.active_storage.previewers || []
ActiveStorage.analyzers = app.config.active_storage.analyzers || []
ActiveStorage.paths = app.config.active_storage.paths || {}

View File

@ -6,7 +6,7 @@ require "database/setup"
class ActiveStorage::VariantTest < ActiveSupport::TestCase
test "resized variation of JPEG blob" do
blob = create_file_blob(filename: "racecar.jpg")
variant = blob.variant(resize: "100x100").processed
variant = blob.variant(resize_to_fit: [100, 100]).processed
assert_match(/racecar\.jpg/, variant.service_url)
image = read_image(variant)
@ -16,7 +16,7 @@ class ActiveStorage::VariantTest < ActiveSupport::TestCase
test "resized and monochrome variation of JPEG blob" do
blob = create_file_blob(filename: "racecar.jpg")
variant = blob.variant(resize: "100x100", monochrome: true).processed
variant = blob.variant(resize_to_fit: [100, 100], monochrome: true).processed
assert_match(/racecar\.jpg/, variant.service_url)
image = read_image(variant)
@ -26,17 +26,24 @@ class ActiveStorage::VariantTest < ActiveSupport::TestCase
end
test "center-weighted crop of JPEG blob" do
blob = create_file_blob(filename: "racecar.jpg")
variant = blob.variant(combine_options: {
gravity: "center",
resize: "100x100^",
crop: "100x100+0+0",
}).processed
assert_match(/racecar\.jpg/, variant.service_url)
begin
ActiveStorage.processor = nil
blob = create_file_blob(filename: "racecar.jpg")
variant = ActiveSupport::Deprecation.silence do
blob.variant(combine_options: {
gravity: "center",
resize: "100x100^",
crop: "100x100+0+0",
}).processed
end
assert_match(/racecar\.jpg/, variant.service_url)
image = read_image(variant)
assert_equal 100, image.width
assert_equal 100, image.height
image = read_image(variant)
assert_equal 100, image.width
assert_equal 100, image.height
ensure
ActiveStorage.processor = :mini_magick
end
end
test "resized variation of PSD blob" do
@ -80,4 +87,20 @@ class ActiveStorage::VariantTest < ActiveSupport::TestCase
variant = blob.variant(font: "a" * 10_000).processed
assert_operator variant.service_url.length, :<, 525
end
test "works for vips processor" do
begin
ActiveStorage.processor = :vips
blob = create_file_blob(filename: "racecar.jpg")
variant = blob.variant(thumbnail_image: 100).processed
image = read_image(variant)
assert_equal 100, image.width
assert_equal 67, image.height
rescue LoadError
# libvips not installed
ensure
ActiveStorage.processor = :mini_magick
end
end
end

View File

@ -7,7 +7,7 @@ require "bundler/setup"
require "active_support"
require "active_support/test_case"
require "active_support/testing/autorun"
require "mini_magick"
require "image_processing/mini_magick"
begin
require "byebug"

View File

@ -337,14 +337,15 @@ rails_blob_path(user.avatar, disposition: "attachment")
Transforming Images
-------------------
To create variation of the image, call `variant` on the Blob.
You can pass any [MiniMagick](https://github.com/minimagick/minimagick)
supported transformation to the method.
To create variation of the image, call `variant` on the Blob. You can pass
any transformation to the method supported by the procecssor. The default
processor is [MiniMagick](https://github.com/minimagick/minimagick), but you
can also use [Vips](http://www.rubydoc.info/gems/ruby-vips/Vips/Image).
To enable variants, add `mini_magick` to your `Gemfile`:
To enable variants, add `image_processing` gem to your `Gemfile`:
```ruby
gem 'mini_magick'
gem 'image_processing', '~> 1.2'
```
When the browser hits the variant URL, Active Storage will lazy transform the
@ -352,7 +353,15 @@ original blob into the format you specified and redirect to its new service
location.
```erb
<%= image_tag user.avatar.variant(resize: "100x100") %>
<%= image_tag user.avatar.variant(resize_to_fit: [100, 100]) %>
```
To switch to the Vips processor, you would add the following to
`config/application.rb`:
```ruby
# Use Vips for processing variants.
config.active_storage.processor = :vips
```
Previewing Files

View File

@ -778,6 +778,8 @@ normal Rails server.
`config.active_storage` provides the following configuration options:
* `config.active_storage.processor` accepts a symbol `:mini_magick` or `:vips`, specifying whether variant transformations will be performed with MiniMagick or ruby-vips. The default is `:mini_magick`.
* `config.active_storage.analyzers` accepts an array of classes indicating the analyzers available for Active Storage blobs. The default is `[ActiveStorage::Analyzer::ImageAnalyzer, ActiveStorage::Analyzer::VideoAnalyzer]`. The former can extract width and height of an image blob; the latter can extract width, height, duration, angle, and aspect ratio of a video blob.
* `config.active_storage.previewers` accepts an array of classes indicating the image previewers available in Active Storage blobs. The default is `[ActiveStorage::Previewer::PDFPreviewer, ActiveStorage::Previewer::VideoPreviewer]`. The former can generate a thumbnail from the first page of a PDF blob; the latter from the relevant frame of a video blob.

View File

@ -23,7 +23,7 @@ ruby <%= "'#{RUBY_VERSION}'" -%>
<% unless skip_active_storage? -%>
# Use ActiveStorage variant
# gem 'mini_magick', '~> 4.8'
# gem 'image_processing', '~> 1.2'
<% end -%>
# Use Capistrano for deployment

View File

@ -312,7 +312,7 @@ class AppGeneratorTest < Rails::Generators::TestCase
def test_active_storage_mini_magick_gem
run_generator
assert_file "Gemfile", /^# gem 'mini_magick'/
assert_file "Gemfile", /^# gem 'image_processing'/
end
def test_mini_magick_gem_when_skip_active_storage_is_given
@ -320,7 +320,7 @@ class AppGeneratorTest < Rails::Generators::TestCase
run_generator [app_root, "--skip-active-storage"]
assert_file "#{app_root}/Gemfile" do |content|
assert_no_match(/gem 'mini_magick'/, content)
assert_no_match(/gem 'image_processing'/, content)
end
end