diff --git a/Gemfile b/Gemfile index 8b7075051f3f140a5a768933aafd8de05198da05..fb1d0b4c56da0ed5661f0136abac4a6b021cb946 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 23b668ac729b6d648d075b8473edab58dda0a47e..c44b772883bee426b2e4c5ab2cabf6950055c89b 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 85ab70dac6ff58ca5a9f9d082f6aff3a63835371..242d5b82161ffa97505706ece3f85c652585cdda 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 d84208419c82df24e6a240f408b06545a3b3bd4e..1cae2078f0662180d33926745385077ad9ba8b35 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 12e7f9f0b53c891e10110c110156086e35ef8866..3bdbc5bacb9a7b79532d472d5ea2aa4794f2daba 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 e1bd9748537fc7e05a97da33bffda8d1d2eae492..817948e6755843055ee88bf5dcade909a5c1a908 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 1385e2aa84164e4dd2432b652c55bc30280599c1..02719e417365b66e9e18f27da00973323230eba3 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 0f3ada25c023b72ba6e8c5391acb0b6d43c71239..1af315b664125d65d6bb44418787c20b65c6c2b4 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 028874f3742ebfc823ab039172aab632c43d9f21..4787eccd090d97cf9e01d973ac37f2d06dbf2ce4 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 d67f65e88a037ac6e5ad97e11ccd8f60ec45cb44..f56db61538c90fb8ce583f5b8157ea7854e80515 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 7d5ca4b8a7c26fb794fa7746292821409182e735..ec950b89cdd6553cdecb2f4cf111d399bd24098b 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 5e7455cdc7a1f8d1407174d75d0ca2f34a28b93d..1567333023327e40172a00b7966924000a1edce7 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 294fdcd6a1c2cc54050a5bf5efc82a6a3b25f4d9..4fcd4b9ba767fee70f7bbd5ec46f7af6d790cf30 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