lib/httpthumbnailer/plugin/thumbnailer.rb in httpthumbnailer-1.2.0 vs lib/httpthumbnailer/plugin/thumbnailer.rb in httpthumbnailer-1.3.0
- old
+ new
@@ -1,39 +1,25 @@
-require 'RMagick'
require 'forwardable'
+require 'httpthumbnailer/plugin'
+require_relative 'thumbnailer/service'
-module MetaData
- def width
- @image.columns
- end
-
- def height
- @image.rows
- end
-
- # ImageMagick Image.mime_type is absolutely bunkers! It goes over file system to look for some strange files WTF?!
- # Also it cannot be used for thumbnails since they are not yet rendered to desired format
- # Here is stupid implementation
- def mime_type
- #TODO: how do I do it better?
- format = @format || @image.format
- mime = case format
- when 'JPG' then 'jpeg'
- else format.downcase
- end
- "image/#{mime}"
- end
-end
-
module Plugin
module Thumbnailer
+ include ClassLogging
+
class UnsupportedMethodError < ArgumentError
def initialize(method)
super("thumbnail method '#{method}' is not supported")
end
end
+ class UnsupportedEditError < ArgumentError
+ def initialize(name)
+ super("no edit with name '#{name}' is supported")
+ end
+ end
+
class UnsupportedMediaTypeError < ArgumentError
def initialize(error)
super("unsupported media type: #{error}")
end
end
@@ -54,431 +40,40 @@
def initialize(color)
super("invalid color name: #{color}")
end
end
- module ImageProcessing
- def replace
- @use_count ||= 0
- processed = nil
- begin
- processed = yield self
- processed = self unless processed
- fail 'got destroyed image' if processed.destroyed?
- ensure
- self.destroy! if @use_count <= 0 unless processed.equal? self
- end
- processed
+ class ThumbnailArgumentError < ArgumentError
+ def initialize(method, msg)
+ super("error while thumbnailing with method '#{method}': #{msg}")
end
-
- def use
- @use_count ||= 0
- @use_count += 1
- begin
- yield self
- self
- ensure
- @use_count -=1
- self.destroy! if @use_count <= 0
- end
- end
end
- class InputImage
- include ClassLogging
- extend Forwardable
-
- def initialize(image, processing_methods, options = {})
- @image = image
- @processing_methods = processing_methods
+ class EditArgumentError < ArgumentError
+ def initialize(name, msg)
+ super("error while applying edit '#{name}': #{msg}")
end
-
- def thumbnail(spec)
- spec = spec.dup
- # default background is white
- spec.options['background-color'] = spec.options.fetch('background-color', 'white').sub(/^0x/, '#')
-
- width = spec.width == :input ? @image.columns : spec.width
- height = spec.height == :input ? @image.rows : spec.height
-
- raise ZeroSizedImageError.new(width, height) if width == 0 or height == 0
-
- begin
- process_image(spec.method, width, height, spec.options).replace do |image|
- if image.alpha?
- log.info 'thumbnail has alpha, rendering on background'
- image.render_on_background(spec.options['background-color'])
- end
- end.use do |image|
- Service.stats.incr_total_thumbnails_created
- image_format = spec.format == :input ? @image.format : spec.format
-
- yield Thumbnail.new(image, image_format, spec.options)
- end
- rescue Magick::ImageMagickError => error
- raise ImageTooLargeError, error.message if error.message =~ /cache resources exhausted/
- raise
- end
- end
-
- def process_image(method, width, height, options)
- @image.replace do |image|
- impl = @processing_methods[method] or raise UnsupportedMethodError, method
- impl.call(image, width, height, options)
- end
- end
-
- # behave as @image in processing
- def use
- @image.use do |image|
- yield self
- end
- end
-
- def_delegators :@image, :destroy!, :destroyed?, :format
-
- include MetaData
-
- # We use base values since it might have been loaded with size hint and prescaled
- def width
- @image.base_columns
- end
-
- def height
- @image.base_rows
- end
-
- # needs to be seen as @image when returned in replace block
- def equal?(image)
- super image or @image.equal? image
- end
end
- class Thumbnail
- include ClassLogging
- extend Forwardable
-
- def initialize(image, format, options = {})
- @image = image
- @format = format
-
- @quality = (options['quality'] or default_quality(format))
- @quality &&= @quality.to_i
-
- @interlace = (options['interlace'] or 'NoInterlace')
- fail "unsupported interlace: #{@interlace}" unless Magick::InterlaceType.values.map(&:to_s).include? @interlace
- @interlace = Magick.const_get @interlace.to_sym
- end
-
- def_delegators :@image, :format
-
- def data
- # export class variables to local scope
- format = @format
- quality = @quality
- interlace = @interlace
-
- @image.to_blob do
- self.format = format
- self.quality = quality if quality
- self.interlace = interlace
- end
- end
-
- include MetaData
-
- private
-
- def default_quality(format)
- case format
- when /png/i
- 95 # max zlib compression, adaptive filtering (photo)
- when /jpeg|jpg/i
- 85
- else
- nil
- end
- end
- end
-
- class Service
- include ClassLogging
-
- extend Stats
- def_stats(
- :total_images_loaded,
- :total_images_reloaded,
- :total_images_downscaled,
- :total_thumbnails_created,
- :images_loaded,
- :max_images_loaded,
- :max_images_loaded_worker,
- :total_images_created,
- :total_images_destroyed,
- :total_images_created_from_blob,
- :total_images_created_initialize,
- :total_images_created_resize,
- :total_images_created_crop,
- :total_images_created_sample
- )
-
- def self.input_formats
- Magick.formats.select do |name, mode|
- mode.include? 'r'
- end.keys.map(&:downcase)
- end
-
- def self.output_formats
- Magick.formats.select do |name, mode|
- mode.include? 'w'
- end.keys.map(&:downcase)
- end
-
- def self.rmagick_version
- Magick::Version
- end
-
- def self.magick_version
- Magick::Magick_version
- end
-
- def initialize(options = {})
- @processing_methods = {}
- @options = options
- @images_loaded = 0
-
- log.info "initializing thumbnailer: #{self.class.rmagick_version} #{self.class.magick_version}"
-
- set_limit(:area, options[:limit_area]) if options.member?(:limit_area)
- set_limit(:memory, options[:limit_memory]) if options.member?(:limit_memory)
- set_limit(:map, options[:limit_map]) if options.member?(:limit_map)
- set_limit(:disk, options[:limit_disk]) if options.member?(:limit_disk)
-
- Magick.trace_proc = lambda do |which, description, id, method|
- case which
- when :c
- Service.stats.incr_images_loaded
- @images_loaded += 1
- Service.stats.max_images_loaded = Service.stats.images_loaded if Service.stats.images_loaded > Service.stats.max_images_loaded
- Service.stats.max_images_loaded_worker = @images_loaded if @images_loaded > Service.stats.max_images_loaded_worker
- Service.stats.incr_total_images_created
- case method
- when :from_blob
- Service.stats.incr_total_images_created_from_blob
- when :initialize
- Service.stats.incr_total_images_created_initialize
- when :resize
- Service.stats.incr_total_images_created_resize
- when :resize!
- Service.stats.incr_total_images_created_resize
- when :crop
- Service.stats.incr_total_images_created_crop
- when :crop!
- Service.stats.incr_total_images_created_crop
- when :sample
- Service.stats.incr_total_images_created_sample
- else
- log.warn "uncounted image creation method: #{method}"
- end
- when :d
- Service.stats.decr_images_loaded
- @images_loaded -= 1
- Service.stats.incr_total_images_destroyed
- end
- log.debug{"image event: #{which}, #{description}, #{id}, #{method}: loaded images: #{Service.stats.images_loaded}"}
- end
- end
-
- def load(io, options = {})
- mw = options[:max_width]
- mh = options[:max_height]
- if mw and mh
- mw = mw.to_i
- mh = mh.to_i
- log.info "using max size hint of: #{mw}x#{mh}"
- end
-
- begin
- blob = io.read
-
- old_memory_limit = nil
- borrowed_memory_limit = nil
- if options.member?(:limit_memory)
- borrowed_memory_limit = options[:limit_memory].borrow(options[:limit_memory].limit, 'image magick')
- old_memory_limit = set_limit(:memory, borrowed_memory_limit)
- end
-
- images = Magick::Image.from_blob(blob) do |info|
- if mw and mh
- define('jpeg', 'size', "#{mw*2}x#{mh*2}")
- define('jbig', 'size', "#{mw*2}x#{mh*2}")
- end
- end
-
- image = images.first
- if image.columns > image.base_columns or image.rows > image.base_rows and not options[:no_reload]
- log.warn "input image got upscaled from: #{image.base_columns}x#{image.base_rows} to #{image.columns}x#{image.rows}: reloading without max size hint!"
- images.each do |other|
- other.destroy!
- end
- images = Magick::Image.from_blob(blob)
- Service.stats.incr_total_images_reloaded
- end
- blob = nil
-
- images.shift.replace do |image|
- images.each do |other|
- other.destroy!
- end
- log.info "loaded image: #{image.inspect}"
- Service.stats.incr_total_images_loaded
-
- # clean up the image
- image.strip!
- image.properties do |key, value|
- log.debug "deleting user propertie '#{key}'"
- image[key] = nil
- end
-
- image
- end.replace do |image|
- if mw and mh and not options[:no_downscale]
- f = image.find_downscale_factor(mw, mh)
- if f > 1
- image = image.downscale(f)
- log.info "downscaled image by factor of #{f}: #{image.inspect}"
- Service.stats.incr_total_images_downscaled
- end
- end
- InputImage.new(image, @processing_methods)
- end
- rescue Magick::ImageMagickError => error
- raise ImageTooLargeError, error if error.message =~ /cache resources exhausted/
- raise UnsupportedMediaTypeError, error
- ensure
- if old_memory_limit
- set_limit(:memory, old_memory_limit)
- options[:limit_memory].return(borrowed_memory_limit, 'image magick')
- end
- end
- end
-
- def processing_method(method, &impl)
- @processing_methods[method] = impl
- end
-
- def set_limit(limit, value)
- old = Magick.limit_resource(limit, value)
- log.info "changed #{limit} limit from #{old} to #{value} bytes"
- old
- end
-
- def setup_default_methods
- processing_method('crop') do |image, width, height, options|
- image.resize_to_fill(width, height, (Float(options['float-x']) rescue 0.5), (Float(options['float-y']) rescue 0.5)) if image.columns != width or image.rows != height
- end
-
- processing_method('fit') do |image, width, height, options|
- image.resize_to_fit(width, height) if image.columns != width or image.rows != height
- end
-
- processing_method('pad') do |image, width, height, options|
- image.resize_to_fit(width, height).replace do |resize|
- resize.render_on_background(options['background-color'], width, height, (Float(options['float-x']) rescue 0.5), (Float(options['float-y']) rescue 0.5))
- end if image.columns != width or image.rows != height
- end
-
- processing_method('limit') do |image, width, height, options|
- image.resize_to_fit(width, height) if image.columns > width or image.rows > height
- end
- end
- end
-
def self.setup(app)
Service.logger = app.logger_for(Service)
- InputImage.logger = app.logger_for(InputImage)
- Thumbnail.logger = app.logger_for(Thumbnail)
+ PluginContext.logger = app.logger_for(PluginContext)
@@service = Service.new(
limit_memory: app.settings[:limit_memory],
limit_map: app.settings[:limit_map],
limit_disk: app.settings[:limit_disk]
)
+ @@service.setup_built_in_plugins
+ end
- @@service.setup_default_methods
+ def self.setup_plugin_from_file(file)
+ log.info("loading plugin from: #{file}")
+ @@service.load_plugin(PluginContext.from_file(file))
end
def thumbnailer
@@service
end
- end
-end
-
-class Magick::Image
- include Plugin::Thumbnailer::ImageProcessing
-
- def render_on_background(background_color, width = nil, height = nil, float_x = 0.5, float_y = 0.5)
- # default to image size
- width ||= self.columns
- height ||= self.rows
-
- # make sure we have enough background to fit image on top of it
- width = self.columns if width < self.columns
- height = self.rows if height < self.rows
-
- Magick::Image.new(width, height) {
- begin
- self.background_color = background_color
- rescue ArgumentError
- raise Plugin::Thumbnailer::InvalidColorNameError.new(background_color)
- end
- self.depth = 8
- }.replace do |background|
- background.composite!(self, *background.float_to_offset(self.columns, self.rows, float_x, float_y), Magick::OverCompositeOp)
- end
- end
-
- # non coping version
- def resize_to_fill(width, height = nil, float_x = 0.5, float_y = 0.5)
- # default to square
- height ||= width
-
- return if width == columns and height == rows
-
- scale = [width / columns.to_f, height / rows.to_f].max
-
- resize((scale * columns).ceil, (scale * rows).ceil).replace do |image|
- next if width == image.columns and height == image.rows
- image.crop(*image.float_to_offset(width, height, float_x, float_y), width, height, true)
- end
- end
-
- def downscale(f)
- sample(columns / f, rows / f)
- end
-
- def find_downscale_factor(max_width, max_height, factor = 1)
- new_factor = factor * 2
- if columns / new_factor > max_width * 2 and rows / new_factor > max_height * 2
- find_downscale_factor(max_width, max_height, factor * 2)
- else
- factor
- end
- end
-
- def float_to_offset(float_width, float_height, float_x = 0.5, float_y = 0.5)
- base_width = self.columns
- base_height = self.rows
-
- x = ((base_width - float_width) * float_x).ceil
- y = ((base_height - float_height) * float_y).ceil
-
- x = 0 if x < 0
- x = (base_width - float_width) if x > (base_width - float_width)
-
- y = 0 if y < 0
- y = (base_height - float_height) if y > (base_height - float_height)
-
- [x, y]
end
end