From ca1296858788780dcb5497e86f66b56170cca279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janko=20Marohni=C4=87?= Date: Fri, 6 Apr 2018 01:48:29 +0200 Subject: [PATCH] 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. --- Gemfile | 2 +- Gemfile.lock | 15 ++-- activestorage/README.md | 2 +- .../app/models/active_storage/variant.rb | 66 +++++++++-------- .../app/models/active_storage/variation.rb | 72 ++++++++++++++----- activestorage/lib/active_storage.rb | 1 + activestorage/lib/active_storage/engine.rb | 1 + activestorage/test/models/variant_test.rb | 47 ++++++++---- activestorage/test/test_helper.rb | 2 +- guides/source/active_storage_overview.md | 21 ++++-- guides/source/configuring.md | 2 + .../generators/rails/app/templates/Gemfile.tt | 2 +- .../test/generators/app_generator_test.rb | 4 +- 13 files changed, 156 insertions(+), 81 deletions(-) diff --git a/Gemfile b/Gemfile index 8b7075051f..fb1d0b4c56 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 23b668ac72..c44b772883 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/activestorage/README.md b/activestorage/README.md index 85ab70dac6..242d5b8216 100644 --- a/activestorage/README.md +++ b/activestorage/README.md @@ -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 diff --git a/activestorage/app/models/active_storage/variant.rb b/activestorage/app/models/active_storage/variant.rb index d84208419c..1cae2078f0 100644 --- a/activestorage/app/models/active_storage/variant.rb +++ b/activestorage/app/models/active_storage/variant.rb @@ -6,8 +6,18 @@ # 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 gem "mini_magick" 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 gem "image_processing" 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 @@ # 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 @@ # 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: +# +# * {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: "100x100", monochrome: true, flip: "-90") +# avatar.variant(resize_to_fit: [100, 100], monochrome: true, flip: "-90") class ActiveStorage::Variant include ActiveStorage::Downloading @@ -82,10 +99,10 @@ def processed? 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 @@ def content_type 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) - end - - def format(image) - image.format("PNG") unless WEB_IMAGE_CONTENT_TYPES.include?(blob.content_type) + format = "png" unless WEB_IMAGE_CONTENT_TYPES.include?(blob.content_type) + variation.transform(image, format: format) end - def upload(image) - File.open(image.path, "r") { |file| service.upload(key, file) } + def upload(file) + service.upload(key, file) end end diff --git a/activestorage/app/models/active_storage/variation.rb b/activestorage/app/models/active_storage/variation.rb index 12e7f9f0b5..3bdbc5bacb 100644 --- a/activestorage/app/models/active_storage/variation.rb +++ b/activestorage/app/models/active_storage/variation.rb @@ -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 @@ def initialize(transformations) @transformations = transformations end - # Accepts an open MiniMagick image instance, like what's returned by MiniMagick::Image.read(io), - # 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 @@ def transform(image) 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) diff --git a/activestorage/lib/active_storage.rb b/activestorage/lib/active_storage.rb index e1bd974853..817948e675 100644 --- a/activestorage/lib/active_storage.rb +++ b/activestorage/lib/active_storage.rb @@ -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: [] diff --git a/activestorage/lib/active_storage/engine.rb b/activestorage/lib/active_storage/engine.rb index 1385e2aa84..02719e4173 100644 --- a/activestorage/lib/active_storage/engine.rb +++ b/activestorage/lib/active_storage/engine.rb @@ -45,6 +45,7 @@ class Engine < Rails::Engine # :nodoc: 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 || {} diff --git a/activestorage/test/models/variant_test.rb b/activestorage/test/models/variant_test.rb index 0f3ada25c0..1af315b664 100644 --- a/activestorage/test/models/variant_test.rb +++ b/activestorage/test/models/variant_test.rb @@ -6,7 +6,7 @@ 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 diff --git a/activestorage/test/test_helper.rb b/activestorage/test/test_helper.rb index 028874f374..4787eccd09 100644 --- a/activestorage/test/test_helper.rb +++ b/activestorage/test/test_helper.rb @@ -7,7 +7,7 @@ require "active_support" require "active_support/test_case" require "active_support/testing/autorun" -require "mini_magick" +require "image_processing/mini_magick" begin require "byebug" diff --git a/guides/source/active_storage_overview.md b/guides/source/active_storage_overview.md index d67f65e88a..f56db61538 100644 --- a/guides/source/active_storage_overview.md +++ b/guides/source/active_storage_overview.md @@ -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 diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 7d5ca4b8a7..ec950b89cd 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -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. diff --git a/railties/lib/rails/generators/rails/app/templates/Gemfile.tt b/railties/lib/rails/generators/rails/app/templates/Gemfile.tt index 5e7455cdc7..1567333023 100644 --- a/railties/lib/rails/generators/rails/app/templates/Gemfile.tt +++ b/railties/lib/rails/generators/rails/app/templates/Gemfile.tt @@ -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 diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index 294fdcd6a1..4fcd4b9ba7 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -312,7 +312,7 @@ def test_app_update_does_not_generate_action_cable_contents_when_skip_action_cab 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 @@ def test_mini_magick_gem_when_skip_active_storage_is_given 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 -- GitLab