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