Add support for libvips in addition to ImageMagick (#30090)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
parent
20e490ba7e
commit
5f15a892fa
16 changed files with 392 additions and 23 deletions
|
@ -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.
|
# [Optional] Uncomment this section to install additional OS packages.
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
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.
|
# [Optional] Uncomment this line to install additional gems.
|
||||||
RUN gem install foreman
|
RUN gem install foreman
|
||||||
|
|
2
.github/actions/setup-ruby/action.yml
vendored
2
.github/actions/setup-ruby/action.yml
vendored
|
@ -14,7 +14,7 @@ runs:
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
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
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
|
|
93
.github/workflows/test-ruby.yml
vendored
93
.github/workflows/test-ruby.yml
vendored
|
@ -133,7 +133,7 @@ jobs:
|
||||||
uses: ./.github/actions/setup-ruby
|
uses: ./.github/actions/setup-ruby
|
||||||
with:
|
with:
|
||||||
ruby-version: ${{ matrix.ruby-version}}
|
ruby-version: ${{ matrix.ruby-version}}
|
||||||
additional-system-dependencies: ffmpeg imagemagick libpam-dev
|
additional-system-dependencies: ffmpeg libpam-dev
|
||||||
|
|
||||||
- name: Load database schema
|
- name: Load database schema
|
||||||
run: './bin/rails db:create db:schema:load db:seed'
|
run: './bin/rails db:create db:schema:load db:seed'
|
||||||
|
@ -148,6 +148,93 @@ jobs:
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
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:
|
test-e2e:
|
||||||
name: End to End testing
|
name: End to End testing
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -209,7 +296,7 @@ jobs:
|
||||||
uses: ./.github/actions/setup-ruby
|
uses: ./.github/actions/setup-ruby
|
||||||
with:
|
with:
|
||||||
ruby-version: ${{ matrix.ruby-version}}
|
ruby-version: ${{ matrix.ruby-version}}
|
||||||
additional-system-dependencies: ffmpeg imagemagick
|
additional-system-dependencies: ffmpeg
|
||||||
|
|
||||||
- name: Set up Javascript environment
|
- name: Set up Javascript environment
|
||||||
uses: ./.github/actions/setup-javascript
|
uses: ./.github/actions/setup-javascript
|
||||||
|
@ -329,7 +416,7 @@ jobs:
|
||||||
uses: ./.github/actions/setup-ruby
|
uses: ./.github/actions/setup-ruby
|
||||||
with:
|
with:
|
||||||
ruby-version: ${{ matrix.ruby-version}}
|
ruby-version: ${{ matrix.ruby-version}}
|
||||||
additional-system-dependencies: ffmpeg imagemagick
|
additional-system-dependencies: ffmpeg
|
||||||
|
|
||||||
- name: Set up Javascript environment
|
- name: Set up Javascript environment
|
||||||
uses: ./.github/actions/setup-javascript
|
uses: ./.github/actions/setup-javascript
|
||||||
|
|
|
@ -43,6 +43,8 @@ ENV \
|
||||||
# Apply Mastodon version information
|
# Apply Mastodon version information
|
||||||
MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
|
MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
|
||||||
MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \
|
MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \
|
||||||
|
# Enable libvips
|
||||||
|
MASTODON_USE_LIBVIPS=true \
|
||||||
# Apply Mastodon static files and YJIT options
|
# Apply Mastodon static files and YJIT options
|
||||||
RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \
|
RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \
|
||||||
RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \
|
RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \
|
||||||
|
@ -97,7 +99,7 @@ RUN \
|
||||||
curl \
|
curl \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
file \
|
file \
|
||||||
imagemagick \
|
libvips42 \
|
||||||
libjemalloc2 \
|
libjemalloc2 \
|
||||||
patchelf \
|
patchelf \
|
||||||
procps \
|
procps \
|
||||||
|
|
1
Gemfile
1
Gemfile
|
@ -23,6 +23,7 @@ gem 'fog-core', '<= 2.4.0'
|
||||||
gem 'fog-openstack', '~> 1.0', require: false
|
gem 'fog-openstack', '~> 1.0', require: false
|
||||||
gem 'kt-paperclip', '~> 7.2'
|
gem 'kt-paperclip', '~> 7.2'
|
||||||
gem 'md-paperclip-azure', '~> 2.2', require: false
|
gem 'md-paperclip-azure', '~> 2.2', require: false
|
||||||
|
gem 'ruby-vips', '~> 2.2', require: false
|
||||||
|
|
||||||
gem 'active_model_serializers', '~> 0.10'
|
gem 'active_model_serializers', '~> 0.10'
|
||||||
gem 'addressable', '~> 2.8'
|
gem 'addressable', '~> 2.8'
|
||||||
|
|
|
@ -763,6 +763,8 @@ GEM
|
||||||
ruby-saml (1.16.0)
|
ruby-saml (1.16.0)
|
||||||
nokogiri (>= 1.13.10)
|
nokogiri (>= 1.13.10)
|
||||||
rexml
|
rexml
|
||||||
|
ruby-vips (2.2.1)
|
||||||
|
ffi (~> 1.12)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.3.2)
|
rubyzip (2.3.2)
|
||||||
rufus-scheduler (3.9.1)
|
rufus-scheduler (3.9.1)
|
||||||
|
@ -1023,6 +1025,7 @@ DEPENDENCIES
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
ruby-prof
|
ruby-prof
|
||||||
ruby-progressbar (~> 1.13)
|
ruby-progressbar (~> 1.13)
|
||||||
|
ruby-vips (~> 2.2)
|
||||||
rubyzip (~> 2.3)
|
rubyzip (~> 2.3)
|
||||||
sanitize (~> 6.0)
|
sanitize (~> 6.0)
|
||||||
scenic (~> 1.7)
|
scenic (~> 1.7)
|
||||||
|
|
|
@ -10,7 +10,7 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def perform_query
|
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
|
end
|
||||||
|
|
||||||
def mastodon_version
|
def mastodon_version
|
||||||
|
@ -71,6 +71,17 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
|
||||||
nil
|
nil
|
||||||
end
|
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
|
def redis_info
|
||||||
@redis_info ||= if redis.is_a?(Redis::Namespace)
|
@redis_info ||= if redis.is_a?(Redis::Namespace)
|
||||||
redis.redis.info
|
redis.redis.info
|
||||||
|
|
|
@ -69,7 +69,7 @@ module Attachmentable
|
||||||
original_extension = Paperclip::Interpolations.extension(attachment, :original)
|
original_extension = Paperclip::Interpolations.extension(attachment, :original)
|
||||||
proper_extension = extensions_for_mime_type.first.to_s
|
proper_extension = extensions_for_mime_type.first.to_s
|
||||||
extension = extensions_for_mime_type.include?(original_extension) ? original_extension : proper_extension
|
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
|
extension
|
||||||
end
|
end
|
||||||
|
|
|
@ -57,7 +57,11 @@ class PreviewCard < ApplicationRecord
|
||||||
has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy
|
has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy
|
||||||
belongs_to :author_account, class_name: 'Account', optional: true
|
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 :url, presence: true, uniqueness: true, url: true
|
||||||
validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
|
validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
|
||||||
|
|
|
@ -27,7 +27,7 @@ require_relative '../lib/sanitize_ext/sanitize_config'
|
||||||
require_relative '../lib/redis/namespace_extensions'
|
require_relative '../lib/redis/namespace_extensions'
|
||||||
require_relative '../lib/paperclip/url_generator_extensions'
|
require_relative '../lib/paperclip/url_generator_extensions'
|
||||||
require_relative '../lib/paperclip/attachment_extensions'
|
require_relative '../lib/paperclip/attachment_extensions'
|
||||||
require_relative '../lib/paperclip/lazy_thumbnail'
|
|
||||||
require_relative '../lib/paperclip/gif_transcoder'
|
require_relative '../lib/paperclip/gif_transcoder'
|
||||||
require_relative '../lib/paperclip/media_type_spoof_detector_extensions'
|
require_relative '../lib/paperclip/media_type_spoof_detector_extensions'
|
||||||
require_relative '../lib/paperclip/transcoder'
|
require_relative '../lib/paperclip/transcoder'
|
||||||
|
@ -100,6 +100,14 @@ module Mastodon
|
||||||
|
|
||||||
config.before_configuration do
|
config.before_configuration do
|
||||||
require 'mastodon/redis_config'
|
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
|
end
|
||||||
|
|
||||||
config.to_prepare do
|
config.to_prepare do
|
||||||
|
|
27
config/initializers/vips.rb
Normal file
27
config/initializers/vips.rb
Normal 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
|
|
@ -5,12 +5,26 @@ module Paperclip
|
||||||
def make
|
def make
|
||||||
return @file unless options[:style] == :small || options[:blurhash]
|
return @file unless options[:style] == :small || options[:blurhash]
|
||||||
|
|
||||||
pixels = convert(':source -depth 8 RGB:-', source: File.expand_path(@file.path)).unpack('C*')
|
width, height, data = blurhash_params
|
||||||
geometry = options.fetch(:file_geometry_parser).from_file(@file)
|
# 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
|
@file
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,15 +7,10 @@ module Paperclip
|
||||||
MIN_CONTRAST = 3.0
|
MIN_CONTRAST = 3.0
|
||||||
ACCENT_MIN_CONTRAST = 2.0
|
ACCENT_MIN_CONTRAST = 2.0
|
||||||
FREQUENCY_THRESHOLD = 0.01
|
FREQUENCY_THRESHOLD = 0.01
|
||||||
|
BINS = 10
|
||||||
|
|
||||||
def make
|
def make
|
||||||
depth = 8
|
background_palette, foreground_palette = Rails.configuration.x.use_vips ? palettes_from_libvips : palettes_from_imagemagick
|
||||||
|
|
||||||
# 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_color = background_palette.first || foreground_palette.first
|
background_color = background_palette.first || foreground_palette.first
|
||||||
foreground_colors = []
|
foreground_colors = []
|
||||||
|
@ -78,6 +73,75 @@ module Paperclip
|
||||||
|
|
||||||
private
|
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)
|
def w3c_contrast(color1, color2)
|
||||||
luminance1 = (color1.to_xyz.y * 0.01) + 0.05
|
luminance1 = (color1.to_xyz.y * 0.01) + 0.05
|
||||||
luminance2 = (color2.to_xyz.y * 0.01) + 0.05
|
luminance2 = (color2.to_xyz.y * 0.01) + 0.05
|
||||||
|
@ -89,7 +153,6 @@ module Paperclip
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# rubocop:disable Naming/MethodParameterName
|
|
||||||
def rgb_to_hsl(r, g, b)
|
def rgb_to_hsl(r, g, b)
|
||||||
r /= 255.0
|
r /= 255.0
|
||||||
g /= 255.0
|
g /= 255.0
|
||||||
|
@ -170,7 +233,7 @@ module Paperclip
|
||||||
ColorDiff::Color::RGB.new(*hsl_to_rgb(hue, saturation, light))
|
ColorDiff::Color::RGB.new(*hsl_to_rgb(hue, saturation, light))
|
||||||
end
|
end
|
||||||
|
|
||||||
def palette_from_histogram(result, quantity)
|
def palette_from_im_histogram(result, quantity)
|
||||||
frequencies = result.scan(/([0-9]+):/).flatten.map(&:to_f)
|
frequencies = result.scan(/([0-9]+):/).flatten.map(&:to_f)
|
||||||
hex_values = result.scan(/\#([0-9A-Fa-f]{6,8})/).flatten
|
hex_values = result.scan(/\#([0-9A-Fa-f]{6,8})/).flatten
|
||||||
total_frequencies = frequencies.sum.to_f
|
total_frequencies = frequencies.sum.to_f
|
||||||
|
|
141
lib/paperclip/vips_lazy_thumbnail.rb
Normal file
141
lib/paperclip/vips_lazy_thumbnail.rb
Normal 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
BIN
spec/fixtures/files/monochrome.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 9 KiB |
|
@ -139,6 +139,12 @@ RSpec.describe MediaAttachment, :paperclip_processing do
|
||||||
it_behaves_like 'static 600x400 image', 'image/png', '.png'
|
it_behaves_like 'static 600x400 image', 'image/png', '.png'
|
||||||
end
|
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
|
describe 'webp' do
|
||||||
let(:media) { Fabricate(:media_attachment, file: attachment_fixture('600x400.webp')) }
|
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.type).to eq 'audio'
|
||||||
expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102)
|
expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102)
|
||||||
expect(media.thumbnail.present?).to be true
|
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'
|
expect(media.file_file_name).to_not eq 'boop.ogg'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue