Skip to content

Commit

Permalink
Change image processing from ImageMagick to libvips
Browse files Browse the repository at this point in the history
  • Loading branch information
Gargron committed May 5, 2024
1 parent 4ef0b48 commit 4e72551
Show file tree
Hide file tree
Showing 18 changed files with 103 additions and 76 deletions.
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: 2 additions & 0 deletions .github/workflows/check-i18n.yml
Expand Up @@ -21,6 +21,8 @@ jobs:

- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
with:
additional-system-dependencies: libvips42

- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test-migrations-one-step.yml
Expand Up @@ -74,6 +74,8 @@ jobs:

- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
with:
additional-system-dependencies: libvips42

- name: Create database
run: './bin/rails db:create'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test-migrations-two-step.yml
Expand Up @@ -74,6 +74,8 @@ jobs:

- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
with:
additional-system-dependencies: libvips42

- name: Create database
run: './bin/rails db:create'
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/test-ruby.yml
Expand Up @@ -39,6 +39,8 @@ jobs:

- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
with:
additional-system-dependencies: libvips42

- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
Expand Down Expand Up @@ -133,7 +135,7 @@ jobs:
uses: ./.github/actions/setup-ruby
with:
ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg imagemagick libpam-dev
additional-system-dependencies: ffmpeg libvips42 libpam-dev

- name: Load database schema
run: './bin/rails db:create db:schema:load db:seed'
Expand Down Expand Up @@ -205,7 +207,7 @@ jobs:
uses: ./.github/actions/setup-ruby
with:
ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg imagemagick
additional-system-dependencies: ffmpeg libvips42

- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
Expand Down Expand Up @@ -309,7 +311,7 @@ jobs:
uses: ./.github/actions/setup-ruby
with:
ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg imagemagick
additional-system-dependencies: ffmpeg libvips42

- 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 @@ -681,6 +681,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 @@ -925,6 +927,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 @@ -41,7 +41,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] || {}))

@file
end
Expand Down
48 changes: 30 additions & 18 deletions lib/paperclip/color_extractor.rb
Expand Up @@ -7,15 +7,18 @@ class ColorExtractor < Paperclip::Processor
MIN_CONTRAST = 3.0
ACCENT_MIN_CONTRAST = 2.0
FREQUENCY_THRESHOLD = 0.01
BINS = 10

def make
depth = 8
image = Vips::Image.new_from_file(@file.path)

# 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)
edge_image = begin
transparent = Vips::Image.black(image.width * 0.75, image.height * 0.75)
image.insert(transparent, (image.width * 0.25) / 2, (image.height * 0.25) / 2)
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 +81,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
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 +114,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 +194,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
52 changes: 43 additions & 9 deletions lib/paperclip/lazy_thumbnail.rb
@@ -1,24 +1,58 @@
# frozen_string_literal: true

module Paperclip
class LazyThumbnail < Paperclip::Thumbnail
class LazyThumbnail < Paperclip::Processor
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)
end

def make
return File.open(@file.path) unless needs_convert?

if options[:geometry]
min_side = [@current_geometry.width, @current_geometry.height].min.to_i
options[:geometry] = "#{min_side}x#{min_side}#" if @target_geometry.square? && min_side < @target_geometry.width
elsif options[:pixels]
width = Math.sqrt(options[:pixels] * (@current_geometry.width.to_f / @current_geometry.height)).round.to_i
height = Math.sqrt(options[:pixels] * (@current_geometry.height.to_f / @current_geometry.width)).round.to_i
options[:geometry] = "#{width}x#{height}>"
dst = TempfileFactory.new.generate([@basename, @format ? ".#{@format}" : ".#{@current_format}"].join)

image = Vips::Image.thumbnail(@file.path, @target_geometry.width, height: @target_geometry.height, **thumbnail_options).mutate do |mutable|
mutable.get_fields.each do |field|
mutable.remove!(field) unless field == 'icc-profile-data'
end
end

Paperclip::Thumbnail.make(file, options, attachment)
image.write_to_file(dst.path, **save_options)

dst
end

private

def thumbnail_options
@crop ? { crop: :centre } : { size: :down }
end

def save_options
case @format
when 'jpg'
{ Q: 90, interlace: true }
else
{}
end
end

def needs_convert?
needs_different_geometry? || needs_different_format? || needs_metadata_stripping?
end
Expand Down

0 comments on commit 4e72551

Please sign in to comment.