httpthumbnailer 1.2.0 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +15 -0
  2. data/Gemfile +4 -3
  3. data/Gemfile.lock +12 -12
  4. data/README.md +242 -68
  5. data/Rakefile +8 -2
  6. data/VERSION +1 -1
  7. data/bin/httpthumbnailer +35 -7
  8. data/lib/httpthumbnailer/error_reporter.rb +4 -2
  9. data/lib/httpthumbnailer/ownership.rb +54 -0
  10. data/lib/httpthumbnailer/plugin.rb +87 -0
  11. data/lib/httpthumbnailer/plugin/thumbnailer.rb +22 -427
  12. data/lib/httpthumbnailer/plugin/thumbnailer/service.rb +163 -0
  13. data/lib/httpthumbnailer/plugin/thumbnailer/service/built_in_plugins.rb +134 -0
  14. data/lib/httpthumbnailer/plugin/thumbnailer/service/images.rb +295 -0
  15. data/lib/httpthumbnailer/plugin/thumbnailer/service/magick.rb +208 -0
  16. data/lib/httpthumbnailer/thumbnail_specs.rb +130 -37
  17. data/lib/httpthumbnailer/thumbnailer.rb +29 -11
  18. metadata +30 -81
  19. data/.rspec +0 -1
  20. data/features/httpthumbnailer.feature +0 -24
  21. data/features/identify.feature +0 -31
  22. data/features/step_definitions/httpthumbnailer_steps.rb +0 -159
  23. data/features/support/env.rb +0 -106
  24. data/features/support/test-large.jpg +0 -0
  25. data/features/support/test-transparent.png +0 -0
  26. data/features/support/test.jpg +0 -0
  27. data/features/support/test.png +0 -0
  28. data/features/support/test.txt +0 -1
  29. data/features/thumbnail.feature +0 -269
  30. data/features/thumbnails.feature +0 -158
  31. data/httpthumbnailer.gemspec +0 -121
  32. data/load_test/extralarge.jpg +0 -0
  33. data/load_test/large.jpg +0 -0
  34. data/load_test/large.png +0 -0
  35. data/load_test/load_test-374846090-1.1.0-rc1-identify-only.csv +0 -3
  36. data/load_test/load_test-374846090-1.1.0-rc1.csv +0 -11
  37. data/load_test/load_test-cd9679c.csv +0 -10
  38. data/load_test/load_test-v0.3.1.csv +0 -10
  39. data/load_test/load_test.jmx +0 -733
  40. data/load_test/medium.jpg +0 -0
  41. data/load_test/small.jpg +0 -0
  42. data/load_test/soak_test-ac0c6bcbe5e-broken-libjpeg-tatoos.csv +0 -11
  43. data/load_test/soak_test-cd9679c.csv +0 -10
  44. data/load_test/soak_test-f98334a-tatoos.csv +0 -11
  45. data/load_test/soak_test.jmx +0 -754
  46. data/load_test/tiny.jpg +0 -0
  47. data/load_test/v0.0.13-loading.csv +0 -7
  48. data/load_test/v0.0.13.csv +0 -7
  49. data/load_test/v0.0.14-no-optimization.csv +0 -10
  50. data/load_test/v0.0.14.csv +0 -10
  51. data/spec/image_processing_spec.rb +0 -148
  52. data/spec/plugin_thumbnailer_spec.rb +0 -318
  53. data/spec/spec_helper.rb +0 -14
  54. data/spec/support/square_even.png +0 -0
  55. data/spec/support/square_odd.png +0 -0
  56. data/spec/support/test_image.rb +0 -16
  57. data/spec/thumbnail_specs_spec.rb +0 -43
data/Rakefile CHANGED
@@ -17,10 +17,16 @@ Jeweler::Tasks.new do |gem|
17
17
  gem.name = "httpthumbnailer"
18
18
  gem.homepage = "http://github.com/jpastuszek/httpthumbnailer"
19
19
  gem.license = "MIT"
20
- gem.summary = %Q{HTTP thumbnailing server}
21
- gem.description = %Q{Provides HTTP API for thumbnailing images}
20
+ gem.summary = %Q{HTTP API server for image thumbnailing, editing and format conversion}
21
+ gem.description = %Q{Statless HTTP server that provides API for thumbnailing images with different aspect ratio keeping methods, applying image edits (like rotate, crop, blur, pixelate, etc.), identification of image format and size and more. It is using ImageMagick or GraphicsMagick via RMagick gem as the image processing library.}
22
22
  gem.email = "jpastuszek@gmail.com"
23
23
  gem.authors = ["Jakub Pastuszek"]
24
+ gem.files.exclude "features/**/*"
25
+ gem.files.exclude "gatling/**/*"
26
+ gem.files.exclude "spec/**/*"
27
+ gem.files.exclude "test_plugins/**/*"
28
+ gem.files.exclude "*.gemspec"
29
+ gem.files.exclude ".rspec"
24
30
  # dependencies defined in Gemfile
25
31
  end
26
32
  Jeweler::RubygemsDotOrgTasks.new
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.3.0
@@ -13,8 +13,18 @@ Application.new('httpthumbnailer', port: 3100) do
13
13
  cast: Integer,
14
14
  description: 'image cache temporary file size limit in MiB',
15
15
  default: 1024
16
+ options :plugins,
17
+ cast: Pathname,
18
+ description: 'path to directory from which plugins will be loaded (files with .rb extension)',
19
+ default: '/usr/share/httpthumbnailer/plugins'
16
20
  switch :no_optimization,
17
- description: 'disable load time size hinting and prescaling optimizations'
21
+ description: 'disable load time size hinting and downsampling optimizations all together'
22
+ switch :reload,
23
+ description: 'reload input images without size hint that got upscaled instead of downsampling (broken JPEG lib)'
24
+ switch :no_upscale_fix,
25
+ description: 'do nothing if image got upscaled when using size hint (broken JPEG lib)'
26
+ switch :no_downsample,
27
+ description: 'disable downsampling of input image before processing'
18
28
  version (Pathname.new(__FILE__).dirname + '..' + 'VERSION').read
19
29
  end
20
30
 
@@ -37,6 +47,9 @@ Application.new('httpthumbnailer', port: 3100) do
37
47
  end
38
48
 
39
49
  Controller.settings[:optimization] = (not settings.no_optimization)
50
+ Controller.settings[:reload] = settings.reload
51
+ Controller.settings[:no_upscale_fix] = settings.no_upscale_fix
52
+ Controller.settings[:no_downsample] = settings.no_downsample
40
53
  Controller.settings[:limit_memory] = settings.limit_memory * 1024**2
41
54
  Controller.settings[:limit_map] = settings.limit_disk * 1024**2
42
55
  Controller.settings[:limit_disk] = settings.limit_disk * 1024**2
@@ -46,7 +59,22 @@ Application.new('httpthumbnailer', port: 3100) do
46
59
  require 'httpthumbnailer/error_reporter'
47
60
  require 'httpthumbnailer/thumbnailer'
48
61
 
49
- class HTTPThumbniler < Controller
62
+ settings.plugins.map do |dir|
63
+ begin
64
+ dir.realpath
65
+ rescue Errno::ENOENT => error
66
+ log.warn "plugin directory '#{dir}' is not accessible: #{error}"
67
+ nil
68
+ end
69
+ end.compact.map do |dir|
70
+ Pathname::glob(dir + '**/*.rb')
71
+ end.each do |plugin_files|
72
+ plugin_files.sort.each do |plugin_file|
73
+ Plugin::Thumbnailer.setup_plugin_from_file(plugin_file)
74
+ end
75
+ end
76
+
77
+ class HTTPThumbnailer < Controller
50
78
  extend Stats
51
79
  def_stats(
52
80
  :workers,
@@ -57,15 +85,15 @@ Application.new('httpthumbnailer', port: 3100) do
57
85
  raindrops_stats = Raindrops::Middleware::Stats.new
58
86
  self.use Raindrops::Middleware, stats: raindrops_stats
59
87
 
60
- StatsReporter << HTTPThumbniler.stats
88
+ StatsReporter << HTTPThumbnailer.stats
61
89
  StatsReporter << raindrops_stats
62
90
  StatsReporter << Plugin::Thumbnailer::Service.stats
63
91
  StatsReporter << Plugin::ResponseHelpers.stats
64
92
 
65
93
  self.define do
66
- HTTPThumbniler.stats.incr_total_requests
94
+ HTTPThumbnailer.stats.incr_total_requests
67
95
  on error? do
68
- HTTPThumbniler.stats.incr_total_errors
96
+ HTTPThumbnailer.stats.incr_total_errors
69
97
  run ErrorReporter
70
98
  end
71
99
 
@@ -87,11 +115,11 @@ Application.new('httpthumbnailer', port: 3100) do
87
115
  end
88
116
  end
89
117
 
90
- HTTPThumbniler
118
+ HTTPThumbnailer
91
119
  end
92
120
 
93
121
  after_fork do |server, worker|
94
- HTTPThumbniler.stats.incr_workers
122
+ HTTPThumbnailer.stats.incr_workers
95
123
  end
96
124
  end
97
125
 
@@ -12,10 +12,12 @@ class ErrorReporter < Controller
12
12
  end
13
13
 
14
14
  on error(
15
- ThumbnailSpec::BadThubnailSpecError,
15
+ ThumbnailSpec::InvalidFormatError,
16
16
  Plugin::Thumbnailer::ZeroSizedImageError,
17
17
  Plugin::Thumbnailer::UnsupportedMethodError,
18
- Plugin::Thumbnailer::InvalidColorNameError
18
+ Plugin::Thumbnailer::InvalidColorNameError,
19
+ Plugin::Thumbnailer::ThumbnailArgumentError,
20
+ Plugin::Thumbnailer::EditArgumentError
19
21
  ) do |error|
20
22
  write_error 400, error
21
23
  end
@@ -0,0 +1,54 @@
1
+ module Ownership
2
+ UseDestroyedError = Class.new(RuntimeError)
3
+ BorrowingDestoryedError = Class.new(RuntimeError)
4
+ BorrowingNotOwnedError = Class.new(RuntimeError)
5
+
6
+ def owned?
7
+ @owned
8
+ end
9
+
10
+ def borrowed?
11
+ @borrowed
12
+ end
13
+
14
+ def borrow
15
+ @destroyed and Kernel::raise BorrowingDestoryedError, "cannot borrow a destroyed obejct '#{self}'"
16
+ @owned or Kernel::raise BorrowingNotOwnedError, "cannot borrow not owned object '#{self}'"
17
+ was_borrowed = @borrowed
18
+ begin
19
+ @borrowed = true
20
+ yield self
21
+ ensure
22
+ @borrowed = was_borrowed
23
+ end
24
+ end
25
+
26
+ def get(&block)
27
+ if @borrowed
28
+ borrow(&block)
29
+ else
30
+ @destroyed and Kernel::raise UseDestroyedError, "cannot own a destoryed object '#{self}'"
31
+ # take ownership; it may be owned already
32
+ @owned = true
33
+ begin
34
+ ret = yield self
35
+ # give up ownership if nothing happened with the obejct
36
+ # NOTE: we use equal here sice == may actually test pixel by pixel which is not the point!
37
+ if ret.equal?(self) or ret.nil?
38
+ @owned = nil
39
+ return self
40
+ end
41
+ ret
42
+ ensure
43
+ # if I am still an owner destroy and give up ownership
44
+ if @owned
45
+ destroy!
46
+ @destroyed = true
47
+ @owned = nil
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+
@@ -0,0 +1,87 @@
1
+ class PluginContext
2
+ include ClassLogging
3
+ PluginArgumentError = Class.new ArgumentError
4
+ include PerfStats
5
+
6
+ attr_reader :thumbnailing_methods
7
+ attr_reader :edits
8
+
9
+ def initialize(&block)
10
+ @thumbnailing_methods = []
11
+ @edits = []
12
+ instance_eval(&block)
13
+ end
14
+
15
+ def self.from_file(file)
16
+ self.new do
17
+ instance_eval file.read, file.to_s
18
+ end
19
+ end
20
+
21
+ def thumbnailing_method(name, &block)
22
+ name.kind_of? String or fail "thumbnailing method name must ba a string; got: #{name.class.name}"
23
+ block.kind_of? Proc or fail "thumbnailing method '#{name}' needs to provide an implementation; got: #{name.class.name}"
24
+ @thumbnailing_methods << [name, block]
25
+ end
26
+
27
+ def edit(name, &block)
28
+ name.kind_of? String or fail "edit name must ba a string; got: #{name.class.name}"
29
+ block.kind_of? Proc or fail "edit '#{name}' needs to provide an implementation; got: #{name.class.name}"
30
+ @edits << [name, block]
31
+ end
32
+
33
+ def with_default(arg, default = nil)
34
+ return default if arg.nil? or arg == ''
35
+ arg
36
+ end
37
+
38
+ # static helpers
39
+ def int!(name, arg, default = nil)
40
+ value = with_default(arg, default) or raise PluginArgumentError, "expected argument '#{name}' to be an integer but got no value"
41
+ begin
42
+ Integer(value)
43
+ rescue ArgumentError
44
+ raise PluginArgumentError, "expected argument '#{name}' to be an integer, got: #{arg.inspect}"
45
+ end
46
+ end
47
+
48
+ def uint!(name, arg, default = nil)
49
+ ret = int!(name, arg, default)
50
+ ret < 0 and raise PluginArgumentError, "expected argument '#{name}' to be an unsigned integer, got negative value: #{arg}"
51
+ ret
52
+ end
53
+
54
+ def float!(name, arg, default = nil)
55
+ value = with_default(arg, default) or raise PluginArgumentError, "expected argument '#{name}' to be a float but got no value"
56
+ begin
57
+ Float(value)
58
+ rescue ArgumentError
59
+ raise PluginArgumentError, "expected argument '#{name}' to be a float, got: #{arg}"
60
+ end
61
+ end
62
+
63
+ def ufloat!(name, arg, default = nil)
64
+ ret = float!(name, arg, default)
65
+ ret < 0 and raise PluginArgumentError, "expected argument '#{name}' to be an unsigned float, got negative value: #{arg}"
66
+ ret
67
+ end
68
+
69
+ def offset_to_center(x, y, w, h)
70
+ [x + w / 2, y + h / 2]
71
+ end
72
+
73
+ def center_to_offset(center_x, center_y, w, h)
74
+ [center_x - w / 2, center_y - h / 2]
75
+ end
76
+
77
+ def normalize_region(x, y, width, height)
78
+ x = 0.0 if x < 0
79
+ y = 0.0 if y < 0
80
+ width = 1.0 - x if width + x > 1
81
+ height = 1.0 - y if height + y > 1
82
+ width = Float::EPSILON if width < 0
83
+ height = Float::EPSILON if height < 0
84
+ [x, y, width, height]
85
+ end
86
+ end
87
+
@@ -1,37 +1,23 @@
1
- require 'RMagick'
2
1
  require 'forwardable'
3
-
4
- module MetaData
5
- def width
6
- @image.columns
7
- end
8
-
9
- def height
10
- @image.rows
11
- end
12
-
13
- # ImageMagick Image.mime_type is absolutely bunkers! It goes over file system to look for some strange files WTF?!
14
- # Also it cannot be used for thumbnails since they are not yet rendered to desired format
15
- # Here is stupid implementation
16
- def mime_type
17
- #TODO: how do I do it better?
18
- format = @format || @image.format
19
- mime = case format
20
- when 'JPG' then 'jpeg'
21
- else format.downcase
22
- end
23
- "image/#{mime}"
24
- end
25
- end
2
+ require 'httpthumbnailer/plugin'
3
+ require_relative 'thumbnailer/service'
26
4
 
27
5
  module Plugin
28
6
  module Thumbnailer
7
+ include ClassLogging
8
+
29
9
  class UnsupportedMethodError < ArgumentError
30
10
  def initialize(method)
31
11
  super("thumbnail method '#{method}' is not supported")
32
12
  end
33
13
  end
34
14
 
15
+ class UnsupportedEditError < ArgumentError
16
+ def initialize(name)
17
+ super("no edit with name '#{name}' is supported")
18
+ end
19
+ end
20
+
35
21
  class UnsupportedMediaTypeError < ArgumentError
36
22
  def initialize(error)
37
23
  super("unsupported media type: #{error}")
@@ -56,355 +42,33 @@ module Plugin
56
42
  end
57
43
  end
58
44
 
59
- module ImageProcessing
60
- def replace
61
- @use_count ||= 0
62
- processed = nil
63
- begin
64
- processed = yield self
65
- processed = self unless processed
66
- fail 'got destroyed image' if processed.destroyed?
67
- ensure
68
- self.destroy! if @use_count <= 0 unless processed.equal? self
69
- end
70
- processed
71
- end
72
-
73
- def use
74
- @use_count ||= 0
75
- @use_count += 1
76
- begin
77
- yield self
78
- self
79
- ensure
80
- @use_count -=1
81
- self.destroy! if @use_count <= 0
82
- end
83
- end
84
- end
85
-
86
- class InputImage
87
- include ClassLogging
88
- extend Forwardable
89
-
90
- def initialize(image, processing_methods, options = {})
91
- @image = image
92
- @processing_methods = processing_methods
93
- end
94
-
95
- def thumbnail(spec)
96
- spec = spec.dup
97
- # default background is white
98
- spec.options['background-color'] = spec.options.fetch('background-color', 'white').sub(/^0x/, '#')
99
-
100
- width = spec.width == :input ? @image.columns : spec.width
101
- height = spec.height == :input ? @image.rows : spec.height
102
-
103
- raise ZeroSizedImageError.new(width, height) if width == 0 or height == 0
104
-
105
- begin
106
- process_image(spec.method, width, height, spec.options).replace do |image|
107
- if image.alpha?
108
- log.info 'thumbnail has alpha, rendering on background'
109
- image.render_on_background(spec.options['background-color'])
110
- end
111
- end.use do |image|
112
- Service.stats.incr_total_thumbnails_created
113
- image_format = spec.format == :input ? @image.format : spec.format
114
-
115
- yield Thumbnail.new(image, image_format, spec.options)
116
- end
117
- rescue Magick::ImageMagickError => error
118
- raise ImageTooLargeError, error.message if error.message =~ /cache resources exhausted/
119
- raise
120
- end
121
- end
122
-
123
- def process_image(method, width, height, options)
124
- @image.replace do |image|
125
- impl = @processing_methods[method] or raise UnsupportedMethodError, method
126
- impl.call(image, width, height, options)
127
- end
128
- end
129
-
130
- # behave as @image in processing
131
- def use
132
- @image.use do |image|
133
- yield self
134
- end
135
- end
136
-
137
- def_delegators :@image, :destroy!, :destroyed?, :format
138
-
139
- include MetaData
140
-
141
- # We use base values since it might have been loaded with size hint and prescaled
142
- def width
143
- @image.base_columns
144
- end
145
-
146
- def height
147
- @image.base_rows
148
- end
149
-
150
- # needs to be seen as @image when returned in replace block
151
- def equal?(image)
152
- super image or @image.equal? image
153
- end
154
- end
155
-
156
- class Thumbnail
157
- include ClassLogging
158
- extend Forwardable
159
-
160
- def initialize(image, format, options = {})
161
- @image = image
162
- @format = format
163
-
164
- @quality = (options['quality'] or default_quality(format))
165
- @quality &&= @quality.to_i
166
-
167
- @interlace = (options['interlace'] or 'NoInterlace')
168
- fail "unsupported interlace: #{@interlace}" unless Magick::InterlaceType.values.map(&:to_s).include? @interlace
169
- @interlace = Magick.const_get @interlace.to_sym
170
- end
171
-
172
- def_delegators :@image, :format
173
-
174
- def data
175
- # export class variables to local scope
176
- format = @format
177
- quality = @quality
178
- interlace = @interlace
179
-
180
- @image.to_blob do
181
- self.format = format
182
- self.quality = quality if quality
183
- self.interlace = interlace
184
- end
185
- end
186
-
187
- include MetaData
188
-
189
- private
190
-
191
- def default_quality(format)
192
- case format
193
- when /png/i
194
- 95 # max zlib compression, adaptive filtering (photo)
195
- when /jpeg|jpg/i
196
- 85
197
- else
198
- nil
199
- end
45
+ class ThumbnailArgumentError < ArgumentError
46
+ def initialize(method, msg)
47
+ super("error while thumbnailing with method '#{method}': #{msg}")
200
48
  end
201
49
  end
202
50
 
203
- class Service
204
- include ClassLogging
205
-
206
- extend Stats
207
- def_stats(
208
- :total_images_loaded,
209
- :total_images_reloaded,
210
- :total_images_downscaled,
211
- :total_thumbnails_created,
212
- :images_loaded,
213
- :max_images_loaded,
214
- :max_images_loaded_worker,
215
- :total_images_created,
216
- :total_images_destroyed,
217
- :total_images_created_from_blob,
218
- :total_images_created_initialize,
219
- :total_images_created_resize,
220
- :total_images_created_crop,
221
- :total_images_created_sample
222
- )
223
-
224
- def self.input_formats
225
- Magick.formats.select do |name, mode|
226
- mode.include? 'r'
227
- end.keys.map(&:downcase)
228
- end
229
-
230
- def self.output_formats
231
- Magick.formats.select do |name, mode|
232
- mode.include? 'w'
233
- end.keys.map(&:downcase)
234
- end
235
-
236
- def self.rmagick_version
237
- Magick::Version
238
- end
239
-
240
- def self.magick_version
241
- Magick::Magick_version
242
- end
243
-
244
- def initialize(options = {})
245
- @processing_methods = {}
246
- @options = options
247
- @images_loaded = 0
248
-
249
- log.info "initializing thumbnailer: #{self.class.rmagick_version} #{self.class.magick_version}"
250
-
251
- set_limit(:area, options[:limit_area]) if options.member?(:limit_area)
252
- set_limit(:memory, options[:limit_memory]) if options.member?(:limit_memory)
253
- set_limit(:map, options[:limit_map]) if options.member?(:limit_map)
254
- set_limit(:disk, options[:limit_disk]) if options.member?(:limit_disk)
255
-
256
- Magick.trace_proc = lambda do |which, description, id, method|
257
- case which
258
- when :c
259
- Service.stats.incr_images_loaded
260
- @images_loaded += 1
261
- Service.stats.max_images_loaded = Service.stats.images_loaded if Service.stats.images_loaded > Service.stats.max_images_loaded
262
- Service.stats.max_images_loaded_worker = @images_loaded if @images_loaded > Service.stats.max_images_loaded_worker
263
- Service.stats.incr_total_images_created
264
- case method
265
- when :from_blob
266
- Service.stats.incr_total_images_created_from_blob
267
- when :initialize
268
- Service.stats.incr_total_images_created_initialize
269
- when :resize
270
- Service.stats.incr_total_images_created_resize
271
- when :resize!
272
- Service.stats.incr_total_images_created_resize
273
- when :crop
274
- Service.stats.incr_total_images_created_crop
275
- when :crop!
276
- Service.stats.incr_total_images_created_crop
277
- when :sample
278
- Service.stats.incr_total_images_created_sample
279
- else
280
- log.warn "uncounted image creation method: #{method}"
281
- end
282
- when :d
283
- Service.stats.decr_images_loaded
284
- @images_loaded -= 1
285
- Service.stats.incr_total_images_destroyed
286
- end
287
- log.debug{"image event: #{which}, #{description}, #{id}, #{method}: loaded images: #{Service.stats.images_loaded}"}
288
- end
289
- end
290
-
291
- def load(io, options = {})
292
- mw = options[:max_width]
293
- mh = options[:max_height]
294
- if mw and mh
295
- mw = mw.to_i
296
- mh = mh.to_i
297
- log.info "using max size hint of: #{mw}x#{mh}"
298
- end
299
-
300
- begin
301
- blob = io.read
302
-
303
- old_memory_limit = nil
304
- borrowed_memory_limit = nil
305
- if options.member?(:limit_memory)
306
- borrowed_memory_limit = options[:limit_memory].borrow(options[:limit_memory].limit, 'image magick')
307
- old_memory_limit = set_limit(:memory, borrowed_memory_limit)
308
- end
309
-
310
- images = Magick::Image.from_blob(blob) do |info|
311
- if mw and mh
312
- define('jpeg', 'size', "#{mw*2}x#{mh*2}")
313
- define('jbig', 'size', "#{mw*2}x#{mh*2}")
314
- end
315
- end
316
-
317
- image = images.first
318
- if image.columns > image.base_columns or image.rows > image.base_rows and not options[:no_reload]
319
- 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!"
320
- images.each do |other|
321
- other.destroy!
322
- end
323
- images = Magick::Image.from_blob(blob)
324
- Service.stats.incr_total_images_reloaded
325
- end
326
- blob = nil
327
-
328
- images.shift.replace do |image|
329
- images.each do |other|
330
- other.destroy!
331
- end
332
- log.info "loaded image: #{image.inspect}"
333
- Service.stats.incr_total_images_loaded
334
-
335
- # clean up the image
336
- image.strip!
337
- image.properties do |key, value|
338
- log.debug "deleting user propertie '#{key}'"
339
- image[key] = nil
340
- end
341
-
342
- image
343
- end.replace do |image|
344
- if mw and mh and not options[:no_downscale]
345
- f = image.find_downscale_factor(mw, mh)
346
- if f > 1
347
- image = image.downscale(f)
348
- log.info "downscaled image by factor of #{f}: #{image.inspect}"
349
- Service.stats.incr_total_images_downscaled
350
- end
351
- end
352
- InputImage.new(image, @processing_methods)
353
- end
354
- rescue Magick::ImageMagickError => error
355
- raise ImageTooLargeError, error if error.message =~ /cache resources exhausted/
356
- raise UnsupportedMediaTypeError, error
357
- ensure
358
- if old_memory_limit
359
- set_limit(:memory, old_memory_limit)
360
- options[:limit_memory].return(borrowed_memory_limit, 'image magick')
361
- end
362
- end
363
- end
364
-
365
- def processing_method(method, &impl)
366
- @processing_methods[method] = impl
367
- end
368
-
369
- def set_limit(limit, value)
370
- old = Magick.limit_resource(limit, value)
371
- log.info "changed #{limit} limit from #{old} to #{value} bytes"
372
- old
373
- end
374
-
375
- def setup_default_methods
376
- processing_method('crop') do |image, width, height, options|
377
- 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
378
- end
379
-
380
- processing_method('fit') do |image, width, height, options|
381
- image.resize_to_fit(width, height) if image.columns != width or image.rows != height
382
- end
383
-
384
- processing_method('pad') do |image, width, height, options|
385
- image.resize_to_fit(width, height).replace do |resize|
386
- resize.render_on_background(options['background-color'], width, height, (Float(options['float-x']) rescue 0.5), (Float(options['float-y']) rescue 0.5))
387
- end if image.columns != width or image.rows != height
388
- end
389
-
390
- processing_method('limit') do |image, width, height, options|
391
- image.resize_to_fit(width, height) if image.columns > width or image.rows > height
392
- end
51
+ class EditArgumentError < ArgumentError
52
+ def initialize(name, msg)
53
+ super("error while applying edit '#{name}': #{msg}")
393
54
  end
394
55
  end
395
56
 
396
57
  def self.setup(app)
397
58
  Service.logger = app.logger_for(Service)
398
- InputImage.logger = app.logger_for(InputImage)
399
- Thumbnail.logger = app.logger_for(Thumbnail)
59
+ PluginContext.logger = app.logger_for(PluginContext)
400
60
 
401
61
  @@service = Service.new(
402
62
  limit_memory: app.settings[:limit_memory],
403
63
  limit_map: app.settings[:limit_map],
404
64
  limit_disk: app.settings[:limit_disk]
405
65
  )
66
+ @@service.setup_built_in_plugins
67
+ end
406
68
 
407
- @@service.setup_default_methods
69
+ def self.setup_plugin_from_file(file)
70
+ log.info("loading plugin from: #{file}")
71
+ @@service.load_plugin(PluginContext.from_file(file))
408
72
  end
409
73
 
410
74
  def thumbnailer
@@ -413,72 +77,3 @@ module Plugin
413
77
  end
414
78
  end
415
79
 
416
- class Magick::Image
417
- include Plugin::Thumbnailer::ImageProcessing
418
-
419
- def render_on_background(background_color, width = nil, height = nil, float_x = 0.5, float_y = 0.5)
420
- # default to image size
421
- width ||= self.columns
422
- height ||= self.rows
423
-
424
- # make sure we have enough background to fit image on top of it
425
- width = self.columns if width < self.columns
426
- height = self.rows if height < self.rows
427
-
428
- Magick::Image.new(width, height) {
429
- begin
430
- self.background_color = background_color
431
- rescue ArgumentError
432
- raise Plugin::Thumbnailer::InvalidColorNameError.new(background_color)
433
- end
434
- self.depth = 8
435
- }.replace do |background|
436
- background.composite!(self, *background.float_to_offset(self.columns, self.rows, float_x, float_y), Magick::OverCompositeOp)
437
- end
438
- end
439
-
440
- # non coping version
441
- def resize_to_fill(width, height = nil, float_x = 0.5, float_y = 0.5)
442
- # default to square
443
- height ||= width
444
-
445
- return if width == columns and height == rows
446
-
447
- scale = [width / columns.to_f, height / rows.to_f].max
448
-
449
- resize((scale * columns).ceil, (scale * rows).ceil).replace do |image|
450
- next if width == image.columns and height == image.rows
451
- image.crop(*image.float_to_offset(width, height, float_x, float_y), width, height, true)
452
- end
453
- end
454
-
455
- def downscale(f)
456
- sample(columns / f, rows / f)
457
- end
458
-
459
- def find_downscale_factor(max_width, max_height, factor = 1)
460
- new_factor = factor * 2
461
- if columns / new_factor > max_width * 2 and rows / new_factor > max_height * 2
462
- find_downscale_factor(max_width, max_height, factor * 2)
463
- else
464
- factor
465
- end
466
- end
467
-
468
- def float_to_offset(float_width, float_height, float_x = 0.5, float_y = 0.5)
469
- base_width = self.columns
470
- base_height = self.rows
471
-
472
- x = ((base_width - float_width) * float_x).ceil
473
- y = ((base_height - float_height) * float_y).ceil
474
-
475
- x = 0 if x < 0
476
- x = (base_width - float_width) if x > (base_width - float_width)
477
-
478
- y = 0 if y < 0
479
- y = (base_height - float_height) if y > (base_height - float_height)
480
-
481
- [x, y]
482
- end
483
- end
484
-