From 5f15a892fa4f01ef9bcf223ec6798b8a9d9945ed Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 5 Jun 2024 21:15:39 +0200 Subject: [PATCH] Add support for libvips in addition to ImageMagick (#30090) Co-authored-by: Claire --- .devcontainer/Dockerfile | 2 +- .github/actions/setup-ruby/action.yml | 2 +- .github/workflows/test-ruby.yml | 93 +++++++++++- Dockerfile | 4 +- Gemfile | 1 + Gemfile.lock | 3 + .../dimension/software_versions_dimension.rb | 13 +- app/models/concerns/attachmentable.rb | 2 +- app/models/preview_card.rb | 6 +- config/application.rb | 10 +- config/initializers/vips.rb | 27 ++++ lib/paperclip/blurhash_transcoder.rb | 20 ++- lib/paperclip/color_extractor.rb | 81 ++++++++-- lib/paperclip/vips_lazy_thumbnail.rb | 141 ++++++++++++++++++ spec/fixtures/files/monochrome.png | Bin 0 -> 9216 bytes spec/models/media_attachment_spec.rb | 10 +- 16 files changed, 392 insertions(+), 23 deletions(-) create mode 100644 config/initializers/vips.rb create mode 100644 lib/paperclip/vips_lazy_thumbnail.rb create mode 100644 spec/fixtures/files/monochrome.png diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 994a41d050..9d8fa2702d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -9,7 +9,7 @@ RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSI # [Optional] Uncomment this section to install additional OS packages. RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ - && apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libpam-dev + && apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev # [Optional] Uncomment this line to install additional gems. RUN gem install foreman diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml index 3a6fba9402..3e232f134c 100644 --- a/.github/actions/setup-ruby/action.yml +++ b/.github/actions/setup-ruby/action.yml @@ -14,7 +14,7 @@ runs: shell: bash run: | sudo apt-get update - sudo apt-get install -y libicu-dev libidn11-dev ${{ inputs.additional-system-dependencies }} + sudo apt-get install -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }} - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 2bfa59e6b1..5f2297381a 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -133,7 +133,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg imagemagick libpam-dev + additional-system-dependencies: ffmpeg libpam-dev - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' @@ -148,6 +148,93 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + test-libvips: + name: Libvips tests + runs-on: ubuntu-24.04 + + needs: + - build + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }} + RAILS_ENV: test + ALLOW_NOPAM: true + PAM_ENABLED: true + PAM_DEFAULT_SERVICE: pam_test + PAM_CONTROLLED_SERVICE: pam_test_controlled + OIDC_ENABLED: true + OIDC_SCOPE: read + SAML_ENABLED: true + CAS_ENABLED: true + BUNDLE_WITH: 'pam_authentication test' + GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} + MASTODON_USE_LIBVIPS: true + + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.1' + - '3.2' + - '.ruby-version' + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: './' + name: ${{ github.sha }} + + - name: Expand archived asset artifacts + run: | + tar xvzf artifacts.tar.gz + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + with: + ruby-version: ${{ matrix.ruby-version}} + additional-system-dependencies: ffmpeg libpam-dev libyaml-dev + + - name: Load database schema + run: './bin/rails db:create db:schema:load db:seed' + + - run: bin/rspec --tag paperclip_processing + + - name: Upload coverage reports to Codecov + if: matrix.ruby-version == '.ruby-version' + uses: codecov/codecov-action@v4 + with: + files: coverage/lcov/mastodon.lcov + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + test-e2e: name: End to End testing runs-on: ubuntu-latest @@ -209,7 +296,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg imagemagick + additional-system-dependencies: ffmpeg - name: Set up Javascript environment uses: ./.github/actions/setup-javascript @@ -329,7 +416,7 @@ jobs: uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - additional-system-dependencies: ffmpeg imagemagick + additional-system-dependencies: ffmpeg - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/Dockerfile b/Dockerfile index c90d5dc980..6d342db437 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,8 @@ ENV \ # Apply Mastodon version information MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \ MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \ +# Enable libvips + MASTODON_USE_LIBVIPS=true \ # Apply Mastodon static files and YJIT options RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \ RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \ @@ -97,7 +99,7 @@ RUN \ curl \ ffmpeg \ file \ - imagemagick \ + libvips42 \ libjemalloc2 \ patchelf \ procps \ diff --git a/Gemfile b/Gemfile index d9de331827..ca32d0cca1 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,7 @@ gem 'fog-core', '<= 2.4.0' gem 'fog-openstack', '~> 1.0', require: false gem 'kt-paperclip', '~> 7.2' gem 'md-paperclip-azure', '~> 2.2', require: false +gem 'ruby-vips', '~> 2.2', require: false gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' diff --git a/Gemfile.lock b/Gemfile.lock index b5192c925a..bf5340a5b0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -763,6 +763,8 @@ GEM ruby-saml (1.16.0) nokogiri (>= 1.13.10) rexml + ruby-vips (2.2.1) + ffi (~> 1.12) ruby2_keywords (0.0.5) rubyzip (2.3.2) rufus-scheduler (3.9.1) @@ -1023,6 +1025,7 @@ DEPENDENCIES rubocop-rspec ruby-prof ruby-progressbar (~> 1.13) + ruby-vips (~> 2.2) rubyzip (~> 2.3) sanitize (~> 6.0) scenic (~> 1.7) diff --git a/app/lib/admin/metrics/dimension/software_versions_dimension.rb b/app/lib/admin/metrics/dimension/software_versions_dimension.rb index 97cdaf589e..9dd0d393f9 100644 --- a/app/lib/admin/metrics/dimension/software_versions_dimension.rb +++ b/app/lib/admin/metrics/dimension/software_versions_dimension.rb @@ -10,7 +10,7 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim protected def perform_query - [mastodon_version, ruby_version, postgresql_version, redis_version, elasticsearch_version].compact + [mastodon_version, ruby_version, postgresql_version, redis_version, elasticsearch_version, libvips_version].compact end def mastodon_version @@ -71,6 +71,17 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim nil end + def libvips_version + return unless Rails.configuration.x.use_vips + + { + key: 'libvips', + human_key: 'libvips', + value: Vips.version_string, + human_value: Vips.version_string, + } + end + def redis_info @redis_info ||= if redis.is_a?(Redis::Namespace) redis.redis.info diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb index f457f5822b..a83e178fc4 100644 --- a/app/models/concerns/attachmentable.rb +++ b/app/models/concerns/attachmentable.rb @@ -69,7 +69,7 @@ module Attachmentable original_extension = Paperclip::Interpolations.extension(attachment, :original) proper_extension = extensions_for_mime_type.first.to_s extension = extensions_for_mime_type.include?(original_extension) ? original_extension : proper_extension - extension = 'jpeg' if extension == 'jpe' + extension = 'jpeg' if ['jpe', 'jfif'].include?(extension) extension end diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb index 11fdd9d88a..cbfc393786 100644 --- a/app/models/preview_card.rb +++ b/app/models/preview_card.rb @@ -57,7 +57,11 @@ class PreviewCard < ApplicationRecord has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy belongs_to :author_account, class_name: 'Account', optional: true - has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 90 +profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, validate_media_type: false + has_attached_file :image, + processors: [Rails.configuration.x.use_vips ? :lazy_thumbnail : :thumbnail, :blurhash_transcoder], + styles: ->(f) { image_styles(f) }, + convert_options: { all: '-quality 90 +profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, + validate_media_type: false validates :url, presence: true, uniqueness: true, url: true validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES diff --git a/config/application.rb b/config/application.rb index a8e313069d..069eb37740 100644 --- a/config/application.rb +++ b/config/application.rb @@ -27,7 +27,7 @@ require_relative '../lib/sanitize_ext/sanitize_config' require_relative '../lib/redis/namespace_extensions' require_relative '../lib/paperclip/url_generator_extensions' require_relative '../lib/paperclip/attachment_extensions' -require_relative '../lib/paperclip/lazy_thumbnail' + require_relative '../lib/paperclip/gif_transcoder' require_relative '../lib/paperclip/media_type_spoof_detector_extensions' require_relative '../lib/paperclip/transcoder' @@ -100,6 +100,14 @@ module Mastodon config.before_configuration do require 'mastodon/redis_config' + + config.x.use_vips = ENV['MASTODON_USE_LIBVIPS'] == 'true' + + if config.x.use_vips + require_relative '../lib/paperclip/vips_lazy_thumbnail' + else + require_relative '../lib/paperclip/lazy_thumbnail' + end end config.to_prepare do diff --git a/config/initializers/vips.rb b/config/initializers/vips.rb new file mode 100644 index 0000000000..25a17b2a17 --- /dev/null +++ b/config/initializers/vips.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +if Rails.configuration.x.use_vips + ENV['VIPS_BLOCK_UNTRUSTED'] = 'true' + + require 'vips' + + abort('Incompatible libvips version, please install libvips >= 8.13') unless Vips.at_least_libvips?(8, 13) + + Vips.block('VipsForeign', true) + + %w( + VipsForeignLoadNsgif + VipsForeignLoadJpeg + VipsForeignLoadPng + VipsForeignLoadWebp + VipsForeignLoadHeif + VipsForeignSavePng + VipsForeignSaveSpng + VipsForeignSaveJpeg + VipsForeignSaveWebp + ).each do |operation| + Vips.block(operation, false) + end + + Vips.block_untrusted(true) +end diff --git a/lib/paperclip/blurhash_transcoder.rb b/lib/paperclip/blurhash_transcoder.rb index c22c20c57a..e9cecef50c 100644 --- a/lib/paperclip/blurhash_transcoder.rb +++ b/lib/paperclip/blurhash_transcoder.rb @@ -5,12 +5,26 @@ module Paperclip def make return @file unless options[:style] == :small || options[:blurhash] - pixels = convert(':source -depth 8 RGB:-', source: File.expand_path(@file.path)).unpack('C*') - geometry = options.fetch(:file_geometry_parser).from_file(@file) + width, height, data = blurhash_params + # Guard against segfaults if data has unexpected size + raise RangeError("Invalid image data size (expected #{width * height * 3}, got #{data.size})") if data.size != width * height * 3 # TODO: should probably be another exception type - attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, **(options[:blurhash] || {})) + attachment.instance.blurhash = Blurhash.encode(width, height, data, **(options[:blurhash] || {})) @file end + + private + + def blurhash_params + if Rails.configuration.x.use_vips + image = Vips::Image.thumbnail(@file.path, 100) + [image.width, image.height, image.colourspace(:srgb).extract_band(0, n: 3).to_a.flatten] + else + pixels = convert(':source -depth 8 RGB:-', source: File.expand_path(@file.path)).unpack('C*') + geometry = options.fetch(:file_geometry_parser).from_file(@file) + [geometry.width, geometry.height, pixels] + end + end end end diff --git a/lib/paperclip/color_extractor.rb b/lib/paperclip/color_extractor.rb index d2f7e7c602..b5992f90bc 100644 --- a/lib/paperclip/color_extractor.rb +++ b/lib/paperclip/color_extractor.rb @@ -7,15 +7,10 @@ module Paperclip MIN_CONTRAST = 3.0 ACCENT_MIN_CONTRAST = 2.0 FREQUENCY_THRESHOLD = 0.01 + BINS = 10 def make - depth = 8 - - # Determine background palette by getting colors close to the image's edge only - background_palette = palette_from_histogram(convert(':source -alpha set -gravity Center -region 75%x75% -fill None -colorize 100% -alpha transparent +region -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10) - - # Determine foreground palette from the whole image - foreground_palette = palette_from_histogram(convert(':source -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10) + background_palette, foreground_palette = Rails.configuration.x.use_vips ? palettes_from_libvips : palettes_from_imagemagick background_color = background_palette.first || foreground_palette.first foreground_colors = [] @@ -78,6 +73,75 @@ module Paperclip private + def palettes_from_libvips + image = downscaled_image + block_edge_dim = (image.height * 0.25).floor + line_edge_dim = (image.width * 0.25).floor + + edge_image = begin + top = image.crop(0, 0, image.width, block_edge_dim) + bottom = image.crop(0, image.height - block_edge_dim, image.width, block_edge_dim) + left = image.crop(0, block_edge_dim, line_edge_dim, image.height - (block_edge_dim * 2)) + right = image.crop(image.width - line_edge_dim, block_edge_dim, line_edge_dim, image.height - (block_edge_dim * 2)) + top.join(bottom, :vertical).join(left, :horizontal).join(right, :horizontal) + end + + background_palette = palette_from_image(edge_image) + foreground_palette = palette_from_image(image) + [background_palette, foreground_palette] + end + + def palettes_from_imagemagick + depth = 8 + + # Determine background palette by getting colors close to the image's edge only + background_palette = palette_from_im_histogram(convert(':source -alpha set -gravity Center -region 75%x75% -fill None -colorize 100% -alpha transparent +region -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10) + + # Determine foreground palette from the whole image + foreground_palette = palette_from_im_histogram(convert(':source -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10) + [background_palette, foreground_palette] + end + + def downscaled_image + image = Vips::Image.new_from_file(@file.path, access: :random).thumbnail_image(100) + + image.colourspace(:srgb).extract_band(0, n: 3) + end + + def palette_from_image(image) + # `hist_find_ndim` will create a BINS×BINS×BINS 3D histogram of the image + # represented as an image of size BINS×BINS with `BINS` bands. + # The number of occurrences of a color (r, g, b) is thus encoded in band `b` at pixel position `(r, g)` + histogram = image.hist_find_ndim(bins: BINS) + + # `histogram.max` returns an array of maxima with their pixel positions, but we don't know in which + # band they are + _, colors = histogram.max(size: 10, out_array: true, x_array: true, y_array: true) + + colors['out_array'].zip(colors['x_array'], colors['y_array']).map do |v, x, y| + rgb_from_xyv(histogram, x, y, v) + end.reverse + end + + # rubocop:disable Naming/MethodParameterName + def rgb_from_xyv(image, x, y, v) + pixel = image.getpoint(x, y) + + # Unfortunately, we only have the first 2 dimensions, so try to + # guess the third one by looking up the value + + # NOTE: this means that if multiple bins with the same `r` and `g` + # components have the same number of occurrences, we will always return + # the one with the lowest `b` value. This means that in case of a tie, + # we will return the same color twice and skip the ones it tied with. + z = pixel.find_index(v) + + r = (x + 0.5) * 256 / BINS + g = (y + 0.5) * 256 / BINS + b = (z + 0.5) * 256 / BINS + ColorDiff::Color::RGB.new(r, g, b) + end + def w3c_contrast(color1, color2) luminance1 = (color1.to_xyz.y * 0.01) + 0.05 luminance2 = (color2.to_xyz.y * 0.01) + 0.05 @@ -89,7 +153,6 @@ module Paperclip end end - # rubocop:disable Naming/MethodParameterName def rgb_to_hsl(r, g, b) r /= 255.0 g /= 255.0 @@ -170,7 +233,7 @@ module Paperclip ColorDiff::Color::RGB.new(*hsl_to_rgb(hue, saturation, light)) end - def palette_from_histogram(result, quantity) + def palette_from_im_histogram(result, quantity) frequencies = result.scan(/([0-9]+):/).flatten.map(&:to_f) hex_values = result.scan(/\#([0-9A-Fa-f]{6,8})/).flatten total_frequencies = frequencies.sum.to_f diff --git a/lib/paperclip/vips_lazy_thumbnail.rb b/lib/paperclip/vips_lazy_thumbnail.rb new file mode 100644 index 0000000000..06d99bf79d --- /dev/null +++ b/lib/paperclip/vips_lazy_thumbnail.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module Paperclip + class LazyThumbnail < Paperclip::Processor + GIF_MAX_FPS = 60 + GIF_MAX_FRAMES = 3000 + GIF_PALETTE_COLORS = 32 + + ALLOWED_FIELDS = %w( + icc-profile-data + ).freeze + + class PixelGeometryParser + def self.parse(current_geometry, pixels) + width = Math.sqrt(pixels * (current_geometry.width.to_f / current_geometry.height)).round.to_i + height = Math.sqrt(pixels * (current_geometry.height.to_f / current_geometry.width)).round.to_i + + Paperclip::Geometry.new(width, height) + end + end + + def initialize(file, options = {}, attachment = nil) + super + + @crop = options[:geometry].to_s[-1, 1] == '#' + @current_geometry = options.fetch(:file_geometry_parser, Geometry).from_file(@file) + @target_geometry = options[:pixels] ? PixelGeometryParser.parse(@current_geometry, options[:pixels]) : options.fetch(:string_geometry_parser, Geometry).parse(options[:geometry].to_s) + @format = options[:format] + @current_format = File.extname(@file.path) + @basename = File.basename(@file.path, @current_format) + + correct_current_format! + end + + def make + return File.open(@file.path) unless needs_convert? + + dst = TempfileFactory.new.generate([@basename, @format ? ".#{@format}" : @current_format].join) + + if preserve_animation? + if @target_geometry.nil? || (@current_geometry.width <= @target_geometry.width && @current_geometry.height <= @target_geometry.height) + target_width = 'iw' + target_height = 'ih' + else + scale = [@target_geometry.width.to_f / @current_geometry.width, @target_geometry.height.to_f / @current_geometry.height].min + target_width = (@current_geometry.width * scale).round + target_height = (@current_geometry.height * scale).round + end + + # The only situation where we use crop on GIFs is cropping them to a square + # aspect ratio, such as for avatars, so this is the only special case we + # implement. If cropping ever becomes necessary for other situations, this will + # need to be expanded. + crop_width = crop_height = [target_width, target_height].min if @target_geometry&.square? + + filter = begin + if @crop + "scale=#{target_width}:#{target_height}:force_original_aspect_ratio=increase,crop=#{crop_width}:#{crop_height}" + else + "scale=#{target_width}:#{target_height}:force_original_aspect_ratio=decrease" + end + end + + command = Terrapin::CommandLine.new(Rails.configuration.x.ffmpeg_binary, '-nostdin -i :source -map_metadata -1 -fpsmax :max_fps -frames:v :max_frames -filter_complex :filter -y :destination', logger: Paperclip.logger) + command.run({ source: @file.path, filter: "#{filter},split[a][b];[a]palettegen=max_colors=#{GIF_PALETTE_COLORS}[p];[b][p]paletteuse=dither=bayer", max_fps: GIF_MAX_FPS, max_frames: GIF_MAX_FRAMES, destination: dst.path }) + else + transformed_image.write_to_file(dst.path, **save_options) + end + + dst + rescue Terrapin::ExitStatusError => e + raise Paperclip::Error, "Error while optimizing #{@basename}: #{e}" + rescue Terrapin::CommandNotFoundError + raise Paperclip::Errors::CommandNotFoundError, 'Could not run the `ffmpeg` command. Please install ffmpeg.' + end + + private + + def correct_current_format! + # If the attachment was uploaded through a base64 payload, the tempfile + # will not have a file extension. It could also have the wrong file extension, + # depending on what the uploaded file was named. We correct for this in the final + # file name, which is however not yet physically in place on the temp file, so we + # need to use it here. Mind that this only reliably works if this processor is + # the first in line and we're working with the original, unmodified file. + @current_format = File.extname(attachment.instance_read(:file_name)) + end + + def transformed_image + # libvips has some optimizations for resizing an image on load. If we don't need to + # resize the image, we have to load it a different way. + if @target_geometry.nil? + Vips::Image.new_from_file(preserve_animation? ? "#{@file.path}[n=-1]" : @file.path, access: :sequential).copy.mutate do |mutable| + (mutable.get_fields - ALLOWED_FIELDS).each do |field| + mutable.remove!(field) + end + end + else + Vips::Image.thumbnail(@file.path, @target_geometry.width, height: @target_geometry.height, **thumbnail_options).mutate do |mutable| + (mutable.get_fields - ALLOWED_FIELDS).each do |field| + mutable.remove!(field) + end + end + end + end + + def thumbnail_options + @crop ? { crop: :centre } : { size: :down } + end + + def save_options + case @format + when 'jpg' + { Q: 90, interlace: true } + else + {} + end + end + + def preserve_animation? + @format == 'gif' || (@format.blank? && @current_format == '.gif') + end + + def needs_convert? + needs_different_geometry? || needs_different_format? || needs_metadata_stripping? + end + + def needs_different_geometry? + (options[:geometry] && @current_geometry.width != @target_geometry.width && @current_geometry.height != @target_geometry.height) || + (options[:pixels] && @current_geometry.width * @current_geometry.height > options[:pixels]) + end + + def needs_different_format? + @format.present? && @current_format != ".#{@format}" + end + + def needs_metadata_stripping? + @attachment.instance.respond_to?(:local?) && @attachment.instance.local? + end + end +end diff --git a/spec/fixtures/files/monochrome.png b/spec/fixtures/files/monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..fa36101cad383620a94c627add680bff8d38c3c2 GIT binary patch literal 9216 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 Rr%5lkm>5{<*TdW*{s*~%YhM5W literal 0 HcmV?d00001 diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb index 1b9a13c38c..221645ac5a 100644 --- a/spec/models/media_attachment_spec.rb +++ b/spec/models/media_attachment_spec.rb @@ -139,6 +139,12 @@ RSpec.describe MediaAttachment, :paperclip_processing do it_behaves_like 'static 600x400 image', 'image/png', '.png' end + describe 'monochrome jpg' do + let(:media) { Fabricate(:media_attachment, file: attachment_fixture('monochrome.png')) } + + it_behaves_like 'static 600x400 image', 'image/png', '.png' + end + describe 'webp' do let(:media) { Fabricate(:media_attachment, file: attachment_fixture('600x400.webp')) } @@ -203,7 +209,9 @@ RSpec.describe MediaAttachment, :paperclip_processing do expect(media.type).to eq 'audio' expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102) expect(media.thumbnail.present?).to be true - expect(media.file.meta['colors']['background']).to eq '#3088d4' + + # NOTE: Our libvips and ImageMagick implementations currently have different results + expect(media.file.meta['colors']['background']).to eq(ENV['MASTODON_USE_LIBVIPS'] ? '#268cd9' : '#3088d4') expect(media.file_file_name).to_not eq 'boop.ogg' end end