Add support for libvips in addition to ImageMagick (#30090)

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Eugen Rochko 2024-06-05 21:15:39 +02:00 committed by GitHub
parent 20e490ba7e
commit 5f15a892fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 392 additions and 23 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 \

View file

@ -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'

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

BIN
spec/fixtures/files/monochrome.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

View file

@ -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