Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change image processing from ImageMagick to libvips #30090

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/Dockerfile
Expand Up @@ -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 libvips42 libpam-dev

# [Optional] Uncomment this line to install additional gems.
RUN gem install foreman
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/setup-ruby/action.yml
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test-ruby.yml
Expand Up @@ -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'
Expand Down Expand Up @@ -209,7 +209,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
Expand Down Expand Up @@ -329,7 +329,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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Expand Up @@ -97,7 +97,7 @@ RUN \
curl \
ffmpeg \
file \
imagemagick \
libvips42 \
libjemalloc2 \
patchelf \
procps \
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Expand Up @@ -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'

gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.8'
Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Expand Up @@ -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)
Expand Down Expand Up @@ -1022,6 +1024,7 @@ DEPENDENCIES
rubocop-rspec
ruby-prof
ruby-progressbar (~> 1.13)
ruby-vips (~> 2.2)
rubyzip (~> 2.3)
sanitize (~> 6.0)
scenic (~> 1.7)
Expand Down
4 changes: 2 additions & 2 deletions app/models/concerns/account/avatar.rb
Expand Up @@ -9,7 +9,7 @@ module Account::Avatar
class_methods do
def avatar_styles(file)
styles = { original: { geometry: '400x400#', file_geometry_parser: FastGeometryParser } }
styles[:static] = { geometry: '400x400#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
styles[:static] = { geometry: '400x400#', format: 'png', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
styles
end

Expand All @@ -18,7 +18,7 @@ def avatar_styles(file)

included do
# Avatar upload
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '+profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, processors: [:lazy_thumbnail]
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, processors: [:lazy_thumbnail]
validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
validates_attachment_size :avatar, less_than: LIMIT
remotable_attachment :avatar, LIMIT, suppress_errors: false
Expand Down
4 changes: 2 additions & 2 deletions app/models/concerns/account/header.rb
Expand Up @@ -10,7 +10,7 @@ module Account::Header
class_methods do
def header_styles(file)
styles = { original: { pixels: MAX_PIXELS, file_geometry_parser: FastGeometryParser } }
styles[:static] = { format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
styles[:static] = { format: 'png', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
styles
end

Expand All @@ -19,7 +19,7 @@ def header_styles(file)

included do
# Header upload
has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '+profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, processors: [:lazy_thumbnail]
has_attached_file :header, styles: ->(f) { header_styles(f) }, processors: [:lazy_thumbnail]
validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
validates_attachment_size :header, less_than: LIMIT
remotable_attachment :header, LIMIT, suppress_errors: false
Expand Down
10 changes: 2 additions & 8 deletions app/models/media_attachment.rb
Expand Up @@ -170,18 +170,13 @@ class MediaAttachment < ApplicationRecord

DEFAULT_STYLES = [:original].freeze

GLOBAL_CONVERT_OPTIONS = {
all: '-quality 90 +profile "!icc,*" +set date:modify +set date:create +set date:timestamp -define jpeg:dct-method=float',
}.freeze

belongs_to :account, inverse_of: :media_attachments, optional: true
belongs_to :status, inverse_of: :media_attachments, optional: true
belongs_to :scheduled_status, inverse_of: :media_attachments, optional: true

has_attached_file :file,
styles: ->(f) { file_styles f },
processors: ->(f) { file_processors f },
convert_options: GLOBAL_CONVERT_OPTIONS
processors: ->(f) { file_processors f }

before_file_validate :set_type_and_extension
before_file_validate :check_video_dimensions
Expand All @@ -192,8 +187,7 @@ class MediaAttachment < ApplicationRecord

has_attached_file :thumbnail,
styles: THUMBNAIL_STYLES,
processors: [:lazy_thumbnail, :blurhash_transcoder, :color_extractor],
convert_options: GLOBAL_CONVERT_OPTIONS
processors: [:lazy_thumbnail, :blurhash_transcoder, :color_extractor]

validates_attachment_content_type :thumbnail, content_type: IMAGE_MIME_TYPES
validates_attachment_size :thumbnail, less_than: IMAGE_LIMIT
Expand Down
2 changes: 1 addition & 1 deletion app/models/preview_card.rb
Expand Up @@ -55,7 +55,7 @@ class PreviewCard < ApplicationRecord

has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy

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: [:lazy_thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, validate_media_type: false

validates :url, presence: true, uniqueness: true, url: true
validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
Expand Down
2 changes: 1 addition & 1 deletion app/models/site_upload.rb
Expand Up @@ -64,7 +64,7 @@ class SiteUpload < ApplicationRecord
mascot: {}.freeze,
}.freeze

has_attached_file :file, styles: ->(file) { STYLES[file.instance.var.to_sym] }, convert_options: { all: '-coalesce +profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, processors: [:lazy_thumbnail, :blurhash_transcoder, :type_corrector]
has_attached_file :file, styles: ->(file) { STYLES[file.instance.var.to_sym] }, processors: [:lazy_thumbnail, :blurhash_transcoder, :type_corrector]

validates_attachment_content_type :file, content_type: %r{\Aimage/.*\z}
validates :file, presence: true
Expand Down
27 changes: 0 additions & 27 deletions config/imagemagick/policy.xml

This file was deleted.

3 changes: 3 additions & 0 deletions config/initializers/vips.rb
@@ -0,0 +1,3 @@
# frozen_string_literal: true

Vips.block_untrusted(true) if Vips.at_least_libvips?(8, 13)
5 changes: 2 additions & 3 deletions lib/paperclip/blurhash_transcoder.rb
Expand Up @@ -5,10 +5,9 @@ class BlurhashTranscoder < Paperclip::Processor
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)
image = Vips::Image.thumbnail(@file.path, 100)

attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, **(options[:blurhash] || {}))
attachment.instance.blurhash = Blurhash.encode(image.width, image.height, image.to_a.flatten, **(options[:blurhash] || {}))
Gargron marked this conversation as resolved.
Show resolved Hide resolved

@file
end
Expand Down
55 changes: 36 additions & 19 deletions lib/paperclip/color_extractor.rb
Expand Up @@ -7,15 +7,23 @@ class ColorExtractor < Paperclip::Processor
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)
image = Vips::Image.new_from_file(@file.path, access: :random).thumbnail_image(100).extract_band(0, n: 3)
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

# 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 = palette_from_image(edge_image)
foreground_palette = palette_from_image(image)

background_color = background_palette.first || foreground_palette.first
foreground_colors = []
Expand Down Expand Up @@ -78,6 +86,28 @@ def make

private

def palette_from_image(image)
histogram = image.hist_find_ndim(bins: BINS)
_, colors = histogram.max(size: 10, out_array: true, x_array: true, y_array: true)

colors['out_array'].map.with_index do |v, i|
x = colors['x_array'][i]
y = colors['y_array'][i]

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)
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
Expand All @@ -89,7 +119,6 @@ def w3c_contrast(color1, color2)
end
end

# rubocop:disable Naming/MethodParameterName
def rgb_to_hsl(r, g, b)
r /= 255.0
g /= 255.0
Expand Down Expand Up @@ -170,18 +199,6 @@ def lighten_or_darken(color, by)
ColorDiff::Color::RGB.new(*hsl_to_rgb(hue, saturation, light))
end

def palette_from_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

frequencies.map.with_index { |f, i| [f / total_frequencies, hex_values[i]] }
.sort_by { |r| -r[0] }
.reject { |r| r[1].size == 8 && r[1].end_with?('00') }
.map { |r| ColorDiff::Color::RGB.new(*r[1][0..5].scan(/../).map { |c| c.to_i(16) }) }
.slice(0, quantity)
end

def rgb_to_hex(rgb)
format('#%02x%02x%02x', rgb.r, rgb.g, rgb.b)
end
Expand Down