httpthumbnailer 0.3.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
|