httpthumbnailer 0.3.1 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +10 -11
- data/Gemfile.lock +75 -51
- data/README.md +184 -54
- data/VERSION +1 -1
- data/bin/httpthumbnailer +76 -133
- data/features/httpthumbnailer.feature +11 -260
- data/features/step_definitions/httpthumbnailer_steps.rb +89 -44
- data/features/support/env.rb +22 -9
- data/features/support/test-large.jpg +0 -0
- data/features/thumbnail.feature +241 -0
- data/features/thumbnails.feature +142 -0
- data/httpthumbnailer.gemspec +39 -40
- data/lib/httpthumbnailer/error_reporter.rb +38 -0
- data/lib/httpthumbnailer/plugin/thumbnailer.rb +396 -0
- data/lib/httpthumbnailer/thumbnail_specs.rb +50 -47
- data/lib/httpthumbnailer/thumbnailer.rb +52 -229
- data/load_test/load_test-cd9679c.csv +10 -0
- data/load_test/load_test-v0.3.1.csv +10 -0
- data/load_test/load_test.jmx +49 -34
- data/load_test/soak_test-ac0c6bcbe5e-broken-libjpeg-tatoos.csv +11 -0
- data/load_test/soak_test-cd9679c.csv +10 -0
- data/load_test/soak_test-f98334a-tatoos.csv +11 -0
- data/load_test/soak_test.jmx +697 -0
- data/spec/image_processing_spec.rb +148 -0
- data/spec/spec_helper.rb +4 -1
- data/spec/thumbnail_specs_spec.rb +18 -5
- metadata +101 -71
- data/lib/httpthumbnailer/multipart_response.rb +0 -45
- data/spec/multipart_response_spec.rb +0 -95
- data/spec/thumbnailer_spec.rb +0 -33
data/httpthumbnailer.gemspec
CHANGED
@@ -5,11 +5,11 @@
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |s|
|
7
7
|
s.name = "httpthumbnailer"
|
8
|
-
s.version = "0.
|
8
|
+
s.version = "1.0.0"
|
9
9
|
|
10
10
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
11
|
s.authors = ["Jakub Pastuszek"]
|
12
|
-
s.date = "
|
12
|
+
s.date = "2013-07-16"
|
13
13
|
s.description = "Provides HTTP API for thumbnailing images"
|
14
14
|
s.email = "jpastuszek@gmail.com"
|
15
15
|
s.executables = ["httpthumbnailer"]
|
@@ -35,81 +35,80 @@ Gem::Specification.new do |s|
|
|
35
35
|
"features/support/test.jpg",
|
36
36
|
"features/support/test.png",
|
37
37
|
"features/support/test.txt",
|
38
|
+
"features/thumbnail.feature",
|
39
|
+
"features/thumbnails.feature",
|
38
40
|
"httpthumbnailer.gemspec",
|
39
|
-
"lib/httpthumbnailer/
|
41
|
+
"lib/httpthumbnailer/error_reporter.rb",
|
42
|
+
"lib/httpthumbnailer/plugin/thumbnailer.rb",
|
40
43
|
"lib/httpthumbnailer/thumbnail_specs.rb",
|
41
44
|
"lib/httpthumbnailer/thumbnailer.rb",
|
42
45
|
"load_test/extralarge.jpg",
|
43
46
|
"load_test/large.jpg",
|
44
47
|
"load_test/large.png",
|
48
|
+
"load_test/load_test-cd9679c.csv",
|
49
|
+
"load_test/load_test-v0.3.1.csv",
|
45
50
|
"load_test/load_test.jmx",
|
46
51
|
"load_test/medium.jpg",
|
47
52
|
"load_test/small.jpg",
|
53
|
+
"load_test/soak_test-ac0c6bcbe5e-broken-libjpeg-tatoos.csv",
|
54
|
+
"load_test/soak_test-cd9679c.csv",
|
55
|
+
"load_test/soak_test-f98334a-tatoos.csv",
|
56
|
+
"load_test/soak_test.jmx",
|
48
57
|
"load_test/tiny.jpg",
|
49
58
|
"load_test/v0.0.13-loading.csv",
|
50
59
|
"load_test/v0.0.13.csv",
|
51
60
|
"load_test/v0.0.14-no-optimization.csv",
|
52
61
|
"load_test/v0.0.14.csv",
|
53
|
-
"spec/
|
62
|
+
"spec/image_processing_spec.rb",
|
54
63
|
"spec/spec_helper.rb",
|
55
|
-
"spec/thumbnail_specs_spec.rb"
|
56
|
-
"spec/thumbnailer_spec.rb"
|
64
|
+
"spec/thumbnail_specs_spec.rb"
|
57
65
|
]
|
58
66
|
s.homepage = "http://github.com/jpastuszek/httpthumbnailer"
|
59
67
|
s.licenses = ["MIT"]
|
60
68
|
s.require_paths = ["lib"]
|
61
|
-
s.rubygems_version = "1.8.
|
69
|
+
s.rubygems_version = "1.8.25"
|
62
70
|
s.summary = "HTTP thumbnailing server"
|
63
71
|
|
64
72
|
if s.respond_to? :specification_version then
|
65
73
|
s.specification_version = 3
|
66
74
|
|
67
75
|
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
68
|
-
s.add_runtime_dependency(%q<
|
69
|
-
s.add_runtime_dependency(%q<mongrel>, [">= 1.2.0.pre2"])
|
76
|
+
s.add_runtime_dependency(%q<unicorn-cuba-base>, ["~> 1.0"])
|
70
77
|
s.add_runtime_dependency(%q<rmagick>, ["~> 2"])
|
71
|
-
s.
|
72
|
-
s.
|
73
|
-
s.add_runtime_dependency(%q<cli>, ["~> 1.1.0"])
|
74
|
-
s.add_development_dependency(%q<rspec>, ["~> 2.3.0"])
|
78
|
+
s.add_development_dependency(%q<rspec>, ["~> 2.13"])
|
79
|
+
s.add_development_dependency(%q<rspec-mocks>, ["~> 2.13"])
|
75
80
|
s.add_development_dependency(%q<cucumber>, [">= 0"])
|
76
|
-
s.add_development_dependency(%q<
|
77
|
-
s.add_development_dependency(%q<jeweler>, ["~> 1.
|
78
|
-
s.add_development_dependency(%q<
|
79
|
-
s.add_development_dependency(%q<daemon>, ["~> 1"])
|
80
|
-
s.add_development_dependency(%q<httpclient>, ["~> 2.2"])
|
81
|
+
s.add_development_dependency(%q<capybara>, ["~> 1.1"])
|
82
|
+
s.add_development_dependency(%q<jeweler>, ["~> 1.8.4"])
|
83
|
+
s.add_development_dependency(%q<httpclient>, ["~> 2.3"])
|
81
84
|
s.add_development_dependency(%q<rdoc>, ["~> 3.9"])
|
85
|
+
s.add_development_dependency(%q<multipart-parser>, ["~> 0.1.1"])
|
86
|
+
s.add_development_dependency(%q<daemon>, ["~> 1.1"])
|
82
87
|
else
|
83
|
-
s.add_dependency(%q<
|
84
|
-
s.add_dependency(%q<mongrel>, [">= 1.2.0.pre2"])
|
88
|
+
s.add_dependency(%q<unicorn-cuba-base>, ["~> 1.0"])
|
85
89
|
s.add_dependency(%q<rmagick>, ["~> 2"])
|
86
|
-
s.add_dependency(%q<
|
87
|
-
s.add_dependency(%q<
|
88
|
-
s.add_dependency(%q<cli>, ["~> 1.1.0"])
|
89
|
-
s.add_dependency(%q<rspec>, ["~> 2.3.0"])
|
90
|
+
s.add_dependency(%q<rspec>, ["~> 2.13"])
|
91
|
+
s.add_dependency(%q<rspec-mocks>, ["~> 2.13"])
|
90
92
|
s.add_dependency(%q<cucumber>, [">= 0"])
|
91
|
-
s.add_dependency(%q<
|
92
|
-
s.add_dependency(%q<jeweler>, ["~> 1.
|
93
|
-
s.add_dependency(%q<
|
94
|
-
s.add_dependency(%q<daemon>, ["~> 1"])
|
95
|
-
s.add_dependency(%q<httpclient>, ["~> 2.2"])
|
93
|
+
s.add_dependency(%q<capybara>, ["~> 1.1"])
|
94
|
+
s.add_dependency(%q<jeweler>, ["~> 1.8.4"])
|
95
|
+
s.add_dependency(%q<httpclient>, ["~> 2.3"])
|
96
96
|
s.add_dependency(%q<rdoc>, ["~> 3.9"])
|
97
|
+
s.add_dependency(%q<multipart-parser>, ["~> 0.1.1"])
|
98
|
+
s.add_dependency(%q<daemon>, ["~> 1.1"])
|
97
99
|
end
|
98
100
|
else
|
99
|
-
s.add_dependency(%q<
|
100
|
-
s.add_dependency(%q<mongrel>, [">= 1.2.0.pre2"])
|
101
|
+
s.add_dependency(%q<unicorn-cuba-base>, ["~> 1.0"])
|
101
102
|
s.add_dependency(%q<rmagick>, ["~> 2"])
|
102
|
-
s.add_dependency(%q<
|
103
|
-
s.add_dependency(%q<
|
104
|
-
s.add_dependency(%q<cli>, ["~> 1.1.0"])
|
105
|
-
s.add_dependency(%q<rspec>, ["~> 2.3.0"])
|
103
|
+
s.add_dependency(%q<rspec>, ["~> 2.13"])
|
104
|
+
s.add_dependency(%q<rspec-mocks>, ["~> 2.13"])
|
106
105
|
s.add_dependency(%q<cucumber>, [">= 0"])
|
107
|
-
s.add_dependency(%q<
|
108
|
-
s.add_dependency(%q<jeweler>, ["~> 1.
|
109
|
-
s.add_dependency(%q<
|
110
|
-
s.add_dependency(%q<daemon>, ["~> 1"])
|
111
|
-
s.add_dependency(%q<httpclient>, ["~> 2.2"])
|
106
|
+
s.add_dependency(%q<capybara>, ["~> 1.1"])
|
107
|
+
s.add_dependency(%q<jeweler>, ["~> 1.8.4"])
|
108
|
+
s.add_dependency(%q<httpclient>, ["~> 2.3"])
|
112
109
|
s.add_dependency(%q<rdoc>, ["~> 3.9"])
|
110
|
+
s.add_dependency(%q<multipart-parser>, ["~> 0.1.1"])
|
111
|
+
s.add_dependency(%q<daemon>, ["~> 1.1"])
|
113
112
|
end
|
114
113
|
end
|
115
114
|
|
@@ -0,0 +1,38 @@
|
|
1
|
+
class ErrorReporter < Controler
|
2
|
+
self.define do
|
3
|
+
on error Rack::UnhandledRequest::UnhandledRequestError do |error|
|
4
|
+
write_error 404, error
|
5
|
+
end
|
6
|
+
|
7
|
+
on error Plugin::Thumbnailer::UnsupportedMediaTypeError do |error|
|
8
|
+
write_error 415, error
|
9
|
+
end
|
10
|
+
|
11
|
+
on error(
|
12
|
+
Plugin::Thumbnailer::ImageTooLargeError,
|
13
|
+
MemoryLimit::MemoryLimitedExceededError
|
14
|
+
) do |error|
|
15
|
+
write_error 413, error
|
16
|
+
end
|
17
|
+
|
18
|
+
on error(
|
19
|
+
ThumbnailSpec::BadThubnailSpecError,
|
20
|
+
Plugin::Thumbnailer::ZeroSizedImageError,
|
21
|
+
Plugin::Thumbnailer::UnsupportedMethodError
|
22
|
+
) do |error|
|
23
|
+
write_error 400, error
|
24
|
+
end
|
25
|
+
|
26
|
+
on error StandardError do |error|
|
27
|
+
log.error "unhandled error while processing request: #{env['REQUEST_METHOD']} #{env['SCRIPT_NAME']}[#{env["PATH_INFO"]}]", error
|
28
|
+
log.debug {
|
29
|
+
out = StringIO.new
|
30
|
+
PP::pp(env, out, 200)
|
31
|
+
"Request: \n" + out.string
|
32
|
+
}
|
33
|
+
|
34
|
+
write_error 500, error
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
@@ -0,0 +1,396 @@
|
|
1
|
+
require 'RMagick'
|
2
|
+
require 'forwardable'
|
3
|
+
|
4
|
+
module Plugin
|
5
|
+
module Thumbnailer
|
6
|
+
class UnsupportedMethodError < ArgumentError
|
7
|
+
def initialize(method)
|
8
|
+
super("thumbnail method '#{method}' is not supported")
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class UnsupportedMediaTypeError < ArgumentError
|
13
|
+
def initialize(error)
|
14
|
+
super("unsupported media type: #{error}")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class ImageTooLargeError < ArgumentError
|
19
|
+
def initialize(error)
|
20
|
+
super("image too large: #{error}")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class ZeroSizedImageError < ArgumentError
|
25
|
+
def initialize(width, height)
|
26
|
+
super("at least one image dimension is zero: #{width}x#{height}")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
module ImageProcessing
|
31
|
+
def replace
|
32
|
+
@use_count ||= 0
|
33
|
+
processed = nil
|
34
|
+
begin
|
35
|
+
processed = yield self
|
36
|
+
processed = self unless processed
|
37
|
+
fail 'got destroyed image' if processed.destroyed?
|
38
|
+
ensure
|
39
|
+
self.destroy! if @use_count <= 0 unless processed.equal? self
|
40
|
+
end
|
41
|
+
processed
|
42
|
+
end
|
43
|
+
|
44
|
+
def use
|
45
|
+
@use_count ||= 0
|
46
|
+
@use_count += 1
|
47
|
+
begin
|
48
|
+
yield self
|
49
|
+
self
|
50
|
+
ensure
|
51
|
+
@use_count -=1
|
52
|
+
self.destroy! if @use_count <= 0
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
class InputImage
|
58
|
+
include ClassLogging
|
59
|
+
extend Forwardable
|
60
|
+
|
61
|
+
def initialize(image, processing_methods, options = {})
|
62
|
+
@image = image
|
63
|
+
@processing_methods = processing_methods
|
64
|
+
end
|
65
|
+
|
66
|
+
def thumbnail(spec)
|
67
|
+
spec = spec.dup
|
68
|
+
# default backgraud is white
|
69
|
+
spec.options['background-color'] = spec.options.fetch('background-color', 'white').sub(/^0x/, '#')
|
70
|
+
|
71
|
+
width = spec.width == :input ? @image.columns : spec.width
|
72
|
+
height = spec.height == :input ? @image.rows : spec.height
|
73
|
+
|
74
|
+
raise ZeroSizedImageError.new(width, height) if width == 0 or height == 0
|
75
|
+
|
76
|
+
begin
|
77
|
+
process_image(spec.method, width, height, spec.options).replace do |image|
|
78
|
+
if image.alpha?
|
79
|
+
log.info 'thumbnail has alpha, rendering on background'
|
80
|
+
image.render_on_background(spec.options['background-color'])
|
81
|
+
end
|
82
|
+
end.use do |image|
|
83
|
+
Service.stats.incr_total_thumbnails_created
|
84
|
+
image_format = spec.format == :input ? @image.format : spec.format
|
85
|
+
|
86
|
+
yield Thumbnail.new(image, image_format, spec.options)
|
87
|
+
end
|
88
|
+
rescue Magick::ImageMagickError => error
|
89
|
+
raise ImageTooLargeError, error.message if error.message =~ /cache resources exhausted/
|
90
|
+
raise
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def process_image(method, width, height, options)
|
95
|
+
@image.replace do |image|
|
96
|
+
impl = @processing_methods[method] or raise UnsupportedMethodError, method
|
97
|
+
impl.call(image, width, height, options)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# behave as @image in processing
|
102
|
+
def use
|
103
|
+
@image.use do |image|
|
104
|
+
yield self
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def_delegators :@image, :destroy!, :destroyed?, :mime_type
|
109
|
+
|
110
|
+
# needs to be seen as @image when returned in replace block
|
111
|
+
def equal?(image)
|
112
|
+
super image or @image.equal? image
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
class Thumbnail
|
117
|
+
include ClassLogging
|
118
|
+
|
119
|
+
def initialize(image, format, options = {})
|
120
|
+
@image = image
|
121
|
+
@format = format
|
122
|
+
@quality = (options['quality'] or default_quality(format))
|
123
|
+
@quality &&= @quality.to_i
|
124
|
+
end
|
125
|
+
|
126
|
+
def data
|
127
|
+
format = @format
|
128
|
+
quality = @quality
|
129
|
+
@image.to_blob do
|
130
|
+
self.format = format
|
131
|
+
self.quality = quality if quality
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def mime_type
|
136
|
+
#@image.mime_type cannot be used since it is raw crated image
|
137
|
+
#TODO: how do I do it better?
|
138
|
+
mime = case @format
|
139
|
+
when 'JPG' then 'jpeg'
|
140
|
+
else @format.downcase
|
141
|
+
end
|
142
|
+
"image/#{mime}"
|
143
|
+
end
|
144
|
+
|
145
|
+
private
|
146
|
+
|
147
|
+
def default_quality(format)
|
148
|
+
case format
|
149
|
+
when /png/i
|
150
|
+
95 # max zlib compression, adaptive filtering (photo)
|
151
|
+
when /jpeg|jpg/i
|
152
|
+
85
|
153
|
+
else
|
154
|
+
nil
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
class Service
|
160
|
+
include ClassLogging
|
161
|
+
|
162
|
+
extend Stats
|
163
|
+
def_stats(
|
164
|
+
:total_images_loaded,
|
165
|
+
:total_images_prescaled,
|
166
|
+
:total_thumbnails_created,
|
167
|
+
:images_loaded,
|
168
|
+
:max_images_loaded,
|
169
|
+
:max_images_loaded_worker,
|
170
|
+
:total_images_created,
|
171
|
+
:total_images_destroyed,
|
172
|
+
:total_images_created_from_blob,
|
173
|
+
:total_images_created_initialize,
|
174
|
+
:total_images_created_resize,
|
175
|
+
:total_images_created_crop,
|
176
|
+
:total_images_created_sample
|
177
|
+
)
|
178
|
+
|
179
|
+
def self.input_formats
|
180
|
+
Magick.formats.select do |name, mode|
|
181
|
+
mode.include? 'r'
|
182
|
+
end.keys.map(&:downcase)
|
183
|
+
end
|
184
|
+
|
185
|
+
def self.output_formats
|
186
|
+
Magick.formats.select do |name, mode|
|
187
|
+
mode.include? 'w'
|
188
|
+
end.keys.map(&:downcase)
|
189
|
+
end
|
190
|
+
|
191
|
+
def self.rmagick_version
|
192
|
+
Magick::Version
|
193
|
+
end
|
194
|
+
|
195
|
+
def self.magick_version
|
196
|
+
Magick::Magick_version
|
197
|
+
end
|
198
|
+
|
199
|
+
def initialize(options = {})
|
200
|
+
@processing_methods = {}
|
201
|
+
@options = options
|
202
|
+
@images_loaded = 0
|
203
|
+
|
204
|
+
log.info "initializing thumbnailer: #{self.class.rmagick_version} #{self.class.magick_version}"
|
205
|
+
|
206
|
+
set_limit(:area, options[:limit_area]) if options.member?(:limit_area)
|
207
|
+
set_limit(:memory, options[:limit_memory]) if options.member?(:limit_memory)
|
208
|
+
set_limit(:map, options[:limit_map]) if options.member?(:limit_map)
|
209
|
+
set_limit(:disk, options[:limit_disk]) if options.member?(:limit_disk)
|
210
|
+
|
211
|
+
Magick.trace_proc = lambda do |which, description, id, method|
|
212
|
+
case which
|
213
|
+
when :c
|
214
|
+
Service.stats.incr_images_loaded
|
215
|
+
@images_loaded += 1
|
216
|
+
Service.stats.max_images_loaded = Service.stats.images_loaded if Service.stats.images_loaded > Service.stats.max_images_loaded
|
217
|
+
Service.stats.max_images_loaded_worker = @images_loaded if @images_loaded > Service.stats.max_images_loaded_worker
|
218
|
+
Service.stats.incr_total_images_created
|
219
|
+
case method
|
220
|
+
when :from_blob
|
221
|
+
Service.stats.incr_total_images_created_from_blob
|
222
|
+
when :initialize
|
223
|
+
Service.stats.incr_total_images_created_initialize
|
224
|
+
when :resize
|
225
|
+
Service.stats.incr_total_images_created_resize
|
226
|
+
when :resize!
|
227
|
+
Service.stats.incr_total_images_created_resize
|
228
|
+
when :crop
|
229
|
+
Service.stats.incr_total_images_created_crop
|
230
|
+
when :crop!
|
231
|
+
Service.stats.incr_total_images_created_crop
|
232
|
+
when :sample
|
233
|
+
Service.stats.incr_total_images_created_sample
|
234
|
+
else
|
235
|
+
log.warn "uncounted image creation method: #{method}"
|
236
|
+
end
|
237
|
+
when :d
|
238
|
+
Service.stats.decr_images_loaded
|
239
|
+
@images_loaded -= 1
|
240
|
+
Service.stats.incr_total_images_destroyed
|
241
|
+
end
|
242
|
+
log.debug{"image event: #{which}, #{description}, #{id}, #{method}: loaded images: #{Service.stats.images_loaded}"}
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def load(io, options = {})
|
247
|
+
mw = options['max-width']
|
248
|
+
mh = options['max-height']
|
249
|
+
if mw and mh
|
250
|
+
mw = mw.to_i
|
251
|
+
mh = mh.to_i
|
252
|
+
log.info "using max size hint of: #{mw}x#{mh}"
|
253
|
+
end
|
254
|
+
|
255
|
+
begin
|
256
|
+
blob = io.read
|
257
|
+
|
258
|
+
old_memory_limit = nil
|
259
|
+
borrowed_memory_limit = nil
|
260
|
+
if options.member?(:limit_memory)
|
261
|
+
borrowed_memory_limit = options[:limit_memory].borrow(options[:limit_memory].limit)
|
262
|
+
old_memory_limit = set_limit(:memory, borrowed_memory_limit)
|
263
|
+
end
|
264
|
+
|
265
|
+
images = Magick::Image.from_blob(blob) do |info|
|
266
|
+
if mw and mh
|
267
|
+
define('jpeg', 'size', "#{mw*2}x#{mh*2}")
|
268
|
+
define('jbig', 'size', "#{mw*2}x#{mh*2}")
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
image = images.first
|
273
|
+
if image.columns > image.base_columns or image.rows > image.base_rows
|
274
|
+
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!"
|
275
|
+
images.each do |other|
|
276
|
+
other.destroy!
|
277
|
+
end
|
278
|
+
images = Magick::Image.from_blob(blob)
|
279
|
+
end
|
280
|
+
blob = nil
|
281
|
+
|
282
|
+
images.shift.replace do |image|
|
283
|
+
images.each do |other|
|
284
|
+
other.destroy!
|
285
|
+
end
|
286
|
+
log.info "loaded image: #{image.inspect}"
|
287
|
+
Service.stats.incr_total_images_loaded
|
288
|
+
image.strip!
|
289
|
+
end.replace do |image|
|
290
|
+
if mw and mh
|
291
|
+
f = image.find_prescale_factor(mw, mh)
|
292
|
+
if f > 1
|
293
|
+
image = image.prescale(f)
|
294
|
+
log.info "prescaled image by factor of #{f}: #{image.inspect}"
|
295
|
+
Service.stats.incr_total_images_prescaled
|
296
|
+
end
|
297
|
+
end
|
298
|
+
InputImage.new(image, @processing_methods)
|
299
|
+
end
|
300
|
+
rescue Magick::ImageMagickError => error
|
301
|
+
raise ImageTooLargeError, error if error.message =~ /cache resources exhausted/
|
302
|
+
raise UnsupportedMediaTypeError, error
|
303
|
+
ensure
|
304
|
+
if old_memory_limit
|
305
|
+
set_limit(:memory, old_memory_limit)
|
306
|
+
options[:limit_memory].return(borrowed_memory_limit)
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
def processing_method(method, &impl)
|
312
|
+
@processing_methods[method] = impl
|
313
|
+
end
|
314
|
+
|
315
|
+
def set_limit(limit, value)
|
316
|
+
old = Magick.limit_resource(limit, value)
|
317
|
+
log.info "changed #{limit} limit from #{old} to #{value} bytes"
|
318
|
+
old
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
def self.setup(app)
|
323
|
+
Service.logger = app.logger_for(Service)
|
324
|
+
InputImage.logger = app.logger_for(InputImage)
|
325
|
+
Thumbnail.logger = app.logger_for(Thumbnail)
|
326
|
+
|
327
|
+
@@service = Service.new(
|
328
|
+
limit_memory: app.settings[:limit_memory],
|
329
|
+
limit_map: app.settings[:limit_map],
|
330
|
+
limit_disk: app.settings[:limit_disk]
|
331
|
+
)
|
332
|
+
|
333
|
+
@@service.processing_method('crop') do |image, width, height, options|
|
334
|
+
image.resize_to_fill(width, height) if image.columns != width or image.rows != height
|
335
|
+
end
|
336
|
+
|
337
|
+
@@service.processing_method('fit') do |image, width, height, options|
|
338
|
+
image.resize_to_fit(width, height) if image.columns != width or image.rows != height
|
339
|
+
end
|
340
|
+
|
341
|
+
@@service.processing_method('pad') do |image, width, height, options|
|
342
|
+
image.resize_to_fit(width, height).replace do |resize|
|
343
|
+
resize.render_on_background(options['background-color'], width, height)
|
344
|
+
end if image.columns != width or image.rows != height
|
345
|
+
end
|
346
|
+
|
347
|
+
@@service.processing_method('limit') do |image, width, height, options|
|
348
|
+
image.resize_to_fit(width, height) if image.columns > width or image.rows > height
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
def thumbnailer
|
353
|
+
@@service
|
354
|
+
end
|
355
|
+
end
|
356
|
+
end
|
357
|
+
|
358
|
+
class Magick::Image
|
359
|
+
include Plugin::Thumbnailer::ImageProcessing
|
360
|
+
|
361
|
+
def render_on_background(background_color, width = nil, height = nil)
|
362
|
+
Magick::Image.new(width || self.columns, height || self.rows) {
|
363
|
+
self.background_color = background_color
|
364
|
+
self.depth = 8
|
365
|
+
}.replace do |background|
|
366
|
+
background.composite!(self, Magick::CenterGravity, Magick::OverCompositeOp)
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
# non coping version
|
371
|
+
def resize_to_fill(ncols, nrows = nil, gravity = Magick::CenterGravity)
|
372
|
+
nrows ||= ncols
|
373
|
+
if ncols != columns or nrows != rows
|
374
|
+
scale = [ncols / columns.to_f, nrows / rows.to_f].max
|
375
|
+
resize(scale * columns + 0.5, scale * rows + 0.5).replace do |image|
|
376
|
+
image.crop(gravity, ncols, nrows, true) if ncols != columns or nrows != rows
|
377
|
+
end
|
378
|
+
else
|
379
|
+
crop(gravity, ncols, nrows, true) if ncols != columns or nrows != rows
|
380
|
+
end
|
381
|
+
end
|
382
|
+
|
383
|
+
def prescale(f)
|
384
|
+
sample(columns / f, rows / f)
|
385
|
+
end
|
386
|
+
|
387
|
+
def find_prescale_factor(max_width, max_height, factor = 1)
|
388
|
+
new_factor = factor * 2
|
389
|
+
if columns / new_factor > max_width * 2 and rows / new_factor > max_height * 2
|
390
|
+
find_prescale_factor(max_width, max_height, factor * 2)
|
391
|
+
else
|
392
|
+
factor
|
393
|
+
end
|
394
|
+
end
|
395
|
+
end
|
396
|
+
|
@@ -1,6 +1,28 @@
|
|
1
|
-
require 'httpthumbnailer/thumbnailer'
|
2
|
-
|
3
1
|
class ThumbnailSpecs < Array
|
2
|
+
def self.from_uri(specs)
|
3
|
+
ts = ThumbnailSpecs.new
|
4
|
+
specs.split('/').each do |spec|
|
5
|
+
ts << ThumbnailSpec.from_uri(spec)
|
6
|
+
end
|
7
|
+
ts
|
8
|
+
end
|
9
|
+
|
10
|
+
def max_width
|
11
|
+
map do |spec|
|
12
|
+
return nil unless spec.width.is_a? Integer
|
13
|
+
spec.width
|
14
|
+
end.max
|
15
|
+
end
|
16
|
+
|
17
|
+
def max_height
|
18
|
+
map do |spec|
|
19
|
+
return nil unless spec.height.is_a? Integer
|
20
|
+
spec.height
|
21
|
+
end.max
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class ThumbnailSpec
|
4
26
|
class BadThubnailSpecError < ArgumentError
|
5
27
|
class MissingArgumentError < BadThubnailSpecError
|
6
28
|
def initialize(spec)
|
@@ -14,67 +36,48 @@ class ThumbnailSpecs < Array
|
|
14
36
|
end
|
15
37
|
end
|
16
38
|
|
17
|
-
class
|
39
|
+
class BadDimensionValueError < BadThubnailSpecError
|
18
40
|
def initialize(value)
|
19
|
-
super "bad
|
41
|
+
super "bad dimension value: #{value}"
|
20
42
|
end
|
21
43
|
end
|
22
44
|
end
|
23
45
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
end
|
46
|
+
def initialize(method, width, height, format, options = {})
|
47
|
+
@method = method
|
48
|
+
@width = cast_dimension(width)
|
49
|
+
@height = cast_dimension(height)
|
50
|
+
@format = (format == 'input' ? :input : format.upcase)
|
51
|
+
@options = options
|
52
|
+
end
|
32
53
|
|
33
|
-
|
54
|
+
def self.from_uri(spec)
|
55
|
+
method, width, height, format, *options = *spec.split(',')
|
56
|
+
raise BadThubnailSpecError::MissingArgumentError.new(spec) unless method and width and height and format
|
34
57
|
|
35
|
-
|
36
|
-
|
58
|
+
opts = {}
|
59
|
+
options.each do |option|
|
60
|
+
key, value = option.split(':')
|
61
|
+
raise BadThubnailSpecError::MissingOptionKeyOrValueError.new(option) unless key and value
|
62
|
+
opts[key] = value
|
37
63
|
end
|
38
64
|
|
39
|
-
|
40
|
-
|
41
|
-
def cast_dimmension(string)
|
42
|
-
return :input if string == 'INPUT'
|
43
|
-
raise BadThubnailSpecError::BadDimmensionValueError.new(string) unless string =~ /^\d+$/
|
44
|
-
string.to_i
|
45
|
-
end
|
65
|
+
ThumbnailSpec.new(method, width, height, format, opts)
|
46
66
|
end
|
47
67
|
|
48
|
-
def self.from_uri(specs)
|
49
|
-
ts = ThumbnailSpecs.new
|
50
|
-
specs.split('/').each do |spec|
|
51
|
-
method, width, height, format, *options = *spec.split(',')
|
52
|
-
raise BadThubnailSpecError::MissingArgumentError.new(spec) unless method and width and height and format
|
53
68
|
|
54
|
-
|
55
|
-
options.each do |option|
|
56
|
-
key, value = option.split(':')
|
57
|
-
raise BadThubnailSpecError::MissingOptionKeyOrValueError.new(option) unless key and value
|
58
|
-
opts[key] = value
|
59
|
-
end
|
69
|
+
attr_reader :method, :width, :height, :format, :options
|
60
70
|
|
61
|
-
|
62
|
-
|
63
|
-
ts
|
71
|
+
def to_s
|
72
|
+
"#{method} #{width}x#{height} (#{format.downcase}) #{options.inspect}"
|
64
73
|
end
|
65
74
|
|
66
|
-
|
67
|
-
map do |spec|
|
68
|
-
return nil unless spec.width.is_a? Integer
|
69
|
-
spec.width
|
70
|
-
end.max
|
71
|
-
end
|
75
|
+
private
|
72
76
|
|
73
|
-
def
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
end.max
|
77
|
+
def cast_dimension(string)
|
78
|
+
return :input if string == 'input'
|
79
|
+
raise BadThubnailSpecError::BadDimensionValueError.new(string) unless string =~ /^\d+$/
|
80
|
+
string.to_i
|
78
81
|
end
|
79
82
|
end
|
80
83
|
|