httpthumbnailer 1.2.0 → 1.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/Gemfile +4 -3
- data/Gemfile.lock +12 -12
- data/README.md +242 -68
- data/Rakefile +8 -2
- data/VERSION +1 -1
- data/bin/httpthumbnailer +35 -7
- data/lib/httpthumbnailer/error_reporter.rb +4 -2
- data/lib/httpthumbnailer/ownership.rb +54 -0
- data/lib/httpthumbnailer/plugin.rb +87 -0
- data/lib/httpthumbnailer/plugin/thumbnailer.rb +22 -427
- data/lib/httpthumbnailer/plugin/thumbnailer/service.rb +163 -0
- data/lib/httpthumbnailer/plugin/thumbnailer/service/built_in_plugins.rb +134 -0
- data/lib/httpthumbnailer/plugin/thumbnailer/service/images.rb +295 -0
- data/lib/httpthumbnailer/plugin/thumbnailer/service/magick.rb +208 -0
- data/lib/httpthumbnailer/thumbnail_specs.rb +130 -37
- data/lib/httpthumbnailer/thumbnailer.rb +29 -11
- metadata +30 -81
- data/.rspec +0 -1
- data/features/httpthumbnailer.feature +0 -24
- data/features/identify.feature +0 -31
- data/features/step_definitions/httpthumbnailer_steps.rb +0 -159
- data/features/support/env.rb +0 -106
- data/features/support/test-large.jpg +0 -0
- data/features/support/test-transparent.png +0 -0
- data/features/support/test.jpg +0 -0
- data/features/support/test.png +0 -0
- data/features/support/test.txt +0 -1
- data/features/thumbnail.feature +0 -269
- data/features/thumbnails.feature +0 -158
- data/httpthumbnailer.gemspec +0 -121
- data/load_test/extralarge.jpg +0 -0
- data/load_test/large.jpg +0 -0
- data/load_test/large.png +0 -0
- data/load_test/load_test-374846090-1.1.0-rc1-identify-only.csv +0 -3
- data/load_test/load_test-374846090-1.1.0-rc1.csv +0 -11
- data/load_test/load_test-cd9679c.csv +0 -10
- data/load_test/load_test-v0.3.1.csv +0 -10
- data/load_test/load_test.jmx +0 -733
- data/load_test/medium.jpg +0 -0
- data/load_test/small.jpg +0 -0
- data/load_test/soak_test-ac0c6bcbe5e-broken-libjpeg-tatoos.csv +0 -11
- data/load_test/soak_test-cd9679c.csv +0 -10
- data/load_test/soak_test-f98334a-tatoos.csv +0 -11
- data/load_test/soak_test.jmx +0 -754
- data/load_test/tiny.jpg +0 -0
- data/load_test/v0.0.13-loading.csv +0 -7
- data/load_test/v0.0.13.csv +0 -7
- data/load_test/v0.0.14-no-optimization.csv +0 -10
- data/load_test/v0.0.14.csv +0 -10
- data/spec/image_processing_spec.rb +0 -148
- data/spec/plugin_thumbnailer_spec.rb +0 -318
- data/spec/spec_helper.rb +0 -14
- data/spec/support/square_even.png +0 -0
- data/spec/support/square_odd.png +0 -0
- data/spec/support/test_image.rb +0 -16
- 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
|
21
|
-
gem.description = %Q{
|
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.
|
1
|
+
1.3.0
|
data/bin/httpthumbnailer
CHANGED
@@ -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
|
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
|
-
|
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 <<
|
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
|
-
|
94
|
+
HTTPThumbnailer.stats.incr_total_requests
|
67
95
|
on error? do
|
68
|
-
|
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
|
-
|
118
|
+
HTTPThumbnailer
|
91
119
|
end
|
92
120
|
|
93
121
|
after_fork do |server, worker|
|
94
|
-
|
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::
|
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
|
-
|
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
|
-
|
60
|
-
def
|
61
|
-
|
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
|
204
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|