diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 994a41d050ffcf9f226908c23e0bc76e993d6f72..9d8fa2702d55bc95fdecb054b8b1262803962df8 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 3a6fba940201a77383984007ec22d35aab22141c..3e232f134c94221d50640ef7d6f94fa66eac3d41 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 2bfa59e6b12177eafc8ee3673bb10538bd835846..5f2297381a7328a374c08a3330156cf99a4a9d0a 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 c90d5dc980634111a75fb08a90f07b5af51e5ff4..6d342db43721b3cb4435d01e4d281de07c912378 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 d9de331827dc906638e899804dc7e0e8ce30f9e8..ca32d0cca1837e77f0af659f35493182ce0966c1 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 b5192c925ad138a56634841cce52258996d1bd56..bf5340a5b03f7f0381684757ffef145b62536214 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 97cdaf589e8c5ec2fc8bee43dfbfdff55e361058..9dd0d393f90bf116e68bbf7f8807fa1e40738f82 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 f457f5822b0b1bfe389830da993e21d34544f42a..a83e178fc4f2c98c3b30c458c53779a4dd25b99c 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 11fdd9d88ac34675423e9c5e3706ad6928bee6f4..cbfc39378652fbe46b0ecad51750b9e9f28d94a8 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 a8e313069d93b73d706762d266e4d87ff31dd39b..069eb3774063cff1f8362b7dbd4ee053325cce56 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 0000000000000000000000000000000000000000..25a17b2a171fca622bd38c496d13638619e431e4
--- /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 c22c20c57ad19ca64e71a8e418f00626e4613700..e9cecef50c88d755c04eb2276ecc878930e0a808 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 d2f7e7c6025bc8fe8f4428003c020c0cfc35e0fd..b5992f90bcbcfca872e982bb6aab7c6d6950bcdb 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 0000000000000000000000000000000000000000..06d99bf79d073f2d2566a29801d2d7dac6a7ada8
--- /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
Binary files /dev/null and b/spec/fixtures/files/monochrome.png differ
diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb
index 1b9a13c38c2d62588421c37af59e8ac366b4a9e8..221645ac5ae3568720c4d5f1de713b2f9a0f794f 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