image_optim 0.17.1 → 0.18.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +8 -8
  2. data/.gitignore +1 -0
  3. data/.travis.yml +5 -18
  4. data/CHANGELOG.markdown +10 -0
  5. data/README.markdown +31 -1
  6. data/bin/image_optim +3 -137
  7. data/image_optim.gemspec +6 -3
  8. data/lib/image_optim.rb +20 -3
  9. data/lib/image_optim/bin_resolver.rb +28 -1
  10. data/lib/image_optim/bin_resolver/bin.rb +17 -7
  11. data/lib/image_optim/cmd.rb +49 -0
  12. data/lib/image_optim/config.rb +64 -4
  13. data/lib/image_optim/image_path.rb +5 -0
  14. data/lib/image_optim/option_definition.rb +5 -3
  15. data/lib/image_optim/runner.rb +1 -2
  16. data/lib/image_optim/runner/option_parser.rb +216 -0
  17. data/lib/image_optim/worker.rb +32 -17
  18. data/lib/image_optim/worker/advpng.rb +7 -1
  19. data/lib/image_optim/worker/gifsicle.rb +16 -3
  20. data/lib/image_optim/worker/jhead.rb +15 -8
  21. data/lib/image_optim/worker/jpegoptim.rb +6 -2
  22. data/lib/image_optim/worker/jpegtran.rb +10 -3
  23. data/lib/image_optim/worker/optipng.rb +6 -1
  24. data/lib/image_optim/worker/pngcrush.rb +8 -1
  25. data/lib/image_optim/worker/pngout.rb +8 -1
  26. data/lib/image_optim/worker/svgo.rb +4 -1
  27. data/script/worker_analysis +523 -0
  28. data/script/worker_analysis.haml +153 -0
  29. data/spec/image_optim/bin_resolver/comparable_condition_spec.rb +4 -5
  30. data/spec/image_optim/bin_resolver/simple_version_spec.rb +44 -21
  31. data/spec/image_optim/bin_resolver_spec.rb +63 -29
  32. data/spec/image_optim/cmd_spec.rb +66 -0
  33. data/spec/image_optim/config_spec.rb +38 -38
  34. data/spec/image_optim/handler_spec.rb +15 -12
  35. data/spec/image_optim/hash_helpers_spec.rb +14 -13
  36. data/spec/image_optim/image_path_spec.rb +22 -7
  37. data/spec/image_optim/runner/glob_helpers_spec.rb +6 -5
  38. data/spec/image_optim/runner/option_parser_spec.rb +99 -0
  39. data/spec/image_optim/space_spec.rb +5 -4
  40. data/spec/image_optim/worker_spec.rb +6 -5
  41. data/spec/image_optim_spec.rb +209 -237
  42. data/spec/spec_helper.rb +3 -0
  43. metadata +43 -11
@@ -4,21 +4,24 @@ require 'image_optim/bin_resolver/error'
4
4
  require 'image_optim/configuration_error'
5
5
  require 'image_optim/option_definition'
6
6
  require 'image_optim/option_helpers'
7
+ require 'image_optim/cmd'
7
8
  require 'shellwords'
8
9
  require 'English'
9
10
 
10
11
  class ImageOptim
11
12
  # Base class for all workers
12
13
  class Worker
14
+ @klasses = []
15
+
13
16
  class << self
14
17
  # List of available workers
15
18
  def klasses
16
- @klasses ||= []
19
+ @klasses.to_enum
17
20
  end
18
21
 
19
22
  # Remember all classes inheriting from this one
20
23
  def inherited(base)
21
- klasses << base
24
+ @klasses << base
22
25
  end
23
26
 
24
27
  # Underscored class name symbol
@@ -36,7 +39,7 @@ class ImageOptim
36
39
  def option(name, default, type, description = nil, &proc)
37
40
  attr_reader name
38
41
  option_definitions <<
39
- OptionDefinition.new(name, default, type, description, &proc)
42
+ OptionDefinition.new(name, default, type, description, &proc)
40
43
  end
41
44
 
42
45
  # Initialize all workers using options from calling options_proc with
@@ -56,6 +59,18 @@ class ImageOptim
56
59
  return if errors.empty?
57
60
  fail BinResolver::Error, ['Bin resolving errors:', *errors].join("\n")
58
61
  end
62
+
63
+ # Resolve all bins of all workers showing warning for missing ones and
64
+ # returning others
65
+ def reject_missing(workers)
66
+ resolved = []
67
+ errors = BinResolver.collect_errors(workers) do |worker|
68
+ worker.resolve_used_bins!
69
+ resolved << worker
70
+ end
71
+ errors.each{ |error| warn error }
72
+ resolved
73
+ end
59
74
  end
60
75
 
61
76
  # Configure (raises on extra options)
@@ -81,9 +96,11 @@ class ImageOptim
81
96
 
82
97
  # Return hash with worker options
83
98
  def options
84
- self.class.option_definitions.each_with_object({}) do |option, h|
85
- h[option.name] = send(option.name)
99
+ hash = {}
100
+ self.class.option_definitions.each do |option|
101
+ hash[option.name] = send(option.name)
86
102
  end
103
+ hash
87
104
  end
88
105
 
89
106
  # Optimize image at src, output at dst, must be overriden in subclass
@@ -111,7 +128,7 @@ class ImageOptim
111
128
  [self.class.bin_sym]
112
129
  end
113
130
 
114
- # Resolve used bins, raise exception mergin all messages
131
+ # Resolve used bins, raise exception concatenating all messages
115
132
  def resolve_used_bins!
116
133
  errors = BinResolver.collect_errors(used_bins) do |bin|
117
134
  @image_optim.resolve_bin!(bin)
@@ -125,6 +142,14 @@ class ImageOptim
125
142
  dst.size? && dst.size < src.size
126
143
  end
127
144
 
145
+ # Short inspect
146
+ def inspect
147
+ options_string = options.map do |name, value|
148
+ " @#{name}=#{value.inspect}"
149
+ end.join(',')
150
+ "#<#{self.class}#{options_string}>"
151
+ end
152
+
128
153
  private
129
154
 
130
155
  def assert_no_unknown_options!(options)
@@ -179,17 +204,7 @@ class ImageOptim
179
204
  nice -n #{@image_optim.nice}
180
205
  #{command} > /dev/null 2>&1
181
206
  ].join(' ')
182
- success = system full_command
183
-
184
- status = $CHILD_STATUS
185
- if status.signaled?
186
- # jruby does not differ non zero exit status and signal number
187
- unless defined?(JRUBY_VERSION) && status.exitstatus == status.termsig
188
- fail SignalException, status.termsig
189
- end
190
- end
191
-
192
- success
207
+ Cmd.run full_command
193
208
  end
194
209
  end
195
210
  end
@@ -17,7 +17,13 @@ class ImageOptim
17
17
 
18
18
  def optimize(src, dst)
19
19
  src.copy(dst)
20
- args = %W[-#{level} -z -q -- #{dst}]
20
+ args = %W[
21
+ --recompress
22
+ -#{level}
23
+ --quiet
24
+ --
25
+ #{dst}
26
+ ]
21
27
  execute(:advpng, *args) && optimized?(src, dst)
22
28
  end
23
29
  end
@@ -7,10 +7,20 @@ class ImageOptim
7
7
  INTERLACE_OPTION =
8
8
  option(:interlace, false, 'Turn interlacing on'){ |v| !!v }
9
9
 
10
+ LEVEL_OPTION =
11
+ option(:level, 3, 'Compression level: '\
12
+ '`1` - light and fast, '\
13
+ '`2` - normal, '\
14
+ '`3` - heavy (slower)') do |v|
15
+ OptionHelpers.limit_with_range(v.to_i, 1..3)
16
+ end
17
+
18
+ CAREFUL_OPTION =
19
+ option(:careful, false, 'Avoid bugs with some software'){ |v| !!v }
20
+
10
21
  def optimize(src, dst)
11
22
  args = %W[
12
- -o #{dst}
13
- -O3
23
+ --output=#{dst}
14
24
  --no-comments
15
25
  --no-names
16
26
  --same-delay
@@ -19,7 +29,10 @@ class ImageOptim
19
29
  --
20
30
  #{src}
21
31
  ]
22
- args.unshift('-i') if interlace
32
+
33
+ args.unshift('--interlace') if interlace
34
+ args.unshift('--careful') if careful
35
+ args.unshift("--optimize=#{level}") if level
23
36
  execute(:gifsicle, *args) && optimized?(src, dst)
24
37
  end
25
38
  end
@@ -22,14 +22,21 @@ class ImageOptim
22
22
  end
23
23
 
24
24
  def optimize(src, dst)
25
- if (2..8).include?(EXIFR::JPEG.new(src.to_s).orientation.to_i)
26
- src.copy(dst)
27
- args = %W[-autorot #{dst}]
28
- resolve_bin!(:jpegtran)
29
- execute(:jhead, *args) && dst.size?
30
- else
31
- false
32
- end
25
+ return false unless oriented?(src)
26
+ src.copy(dst)
27
+ args = %W[
28
+ -autorot
29
+ #{dst}
30
+ ]
31
+ resolve_bin!(:jpegtran)
32
+ execute(:jhead, *args) && dst.size?
33
+ end
34
+
35
+ private
36
+
37
+ def oriented?(image)
38
+ exif = EXIFR::JPEG.new(image.to_s)
39
+ (2..8).include?(exif.orientation.to_i)
33
40
  end
34
41
  end
35
42
  end
@@ -34,11 +34,15 @@ class ImageOptim
34
34
 
35
35
  def optimize(src, dst)
36
36
  src.copy(dst)
37
- args = %W[-q -- #{dst}]
37
+ args = %W[
38
+ --quiet
39
+ --
40
+ #{dst}
41
+ ]
38
42
  strip.each do |strip_marker|
39
43
  args.unshift "--strip-#{strip_marker}"
40
44
  end
41
- args.unshift "-m#{max_quality}" if max_quality < 100
45
+ args.unshift "--max=#{max_quality}" if max_quality < 100
42
46
  execute(:jpegoptim, *args) && optimized?(src, dst)
43
47
  end
44
48
  end
@@ -23,13 +23,20 @@ class ImageOptim
23
23
 
24
24
  def optimize(src, dst)
25
25
  if jpegrescan
26
- args = %W[#{src} #{dst}]
26
+ args = %W[
27
+ #{src}
28
+ #{dst}
29
+ ]
27
30
  args.unshift '-s' unless copy_chunks
28
31
  resolve_bin!(:jpegtran)
29
32
  execute(:jpegrescan, *args) && optimized?(src, dst)
30
33
  else
31
- args = %W[-optimize -outfile #{dst} #{src}]
32
- args.unshift '-copy', copy_chunks ? 'all' : 'none'
34
+ args = %W[
35
+ -optimize
36
+ -outfile #{dst}
37
+ #{src}
38
+ ]
39
+ args.unshift '-copy', (copy_chunks ? 'all' : 'none')
33
40
  args.unshift '-progressive' if progressive
34
41
  execute(:jpegtran, *args) && optimized?(src, dst)
35
42
  end
@@ -23,7 +23,12 @@ class ImageOptim
23
23
 
24
24
  def optimize(src, dst)
25
25
  src.copy(dst)
26
- args = %W[-o#{level} -quiet -- #{dst}]
26
+ args = %W[
27
+ -o #{level}
28
+ -quiet
29
+ --
30
+ #{dst}
31
+ ]
27
32
  args.unshift "-i#{interlace ? 1 : 0}" unless interlace.nil?
28
33
  execute(:optipng, *args) && optimized?(src, dst)
29
34
  end
@@ -25,7 +25,14 @@ class ImageOptim
25
25
  end
26
26
 
27
27
  def optimize(src, dst)
28
- args = %W[-reduce -cc -q -- #{src} #{dst}]
28
+ args = %W[
29
+ -reduce
30
+ -cc
31
+ -q
32
+ --
33
+ #{src}
34
+ #{dst}
35
+ ]
29
36
  chunks.each do |chunk|
30
37
  args.unshift '-rem', chunk
31
38
  end
@@ -24,7 +24,14 @@ class ImageOptim
24
24
  end
25
25
 
26
26
  def optimize(src, dst)
27
- args = %W[-k#{copy_chunks ? 1 : 0} -s#{strategy} -q -y #{src} #{dst}]
27
+ args = %W[
28
+ -k#{copy_chunks ? 1 : 0}
29
+ -s#{strategy}
30
+ -q
31
+ -y
32
+ #{src}
33
+ #{dst}
34
+ ]
28
35
  execute(:pngout, *args) && optimized?(src, dst)
29
36
  end
30
37
  end
@@ -5,7 +5,10 @@ class ImageOptim
5
5
  # https://github.com/svg/svgo
6
6
  class Svgo < Worker
7
7
  def optimize(src, dst)
8
- args = %W[-i #{src} -o #{dst}]
8
+ args = %W[
9
+ --input #{src}
10
+ --output #{dst}
11
+ ]
9
12
  execute(:svgo, *args) && optimized?(src, dst)
10
13
  end
11
14
  end
@@ -0,0 +1,523 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+
4
+ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
5
+
6
+ require 'image_optim'
7
+ require 'image_optim/cmd'
8
+ require 'progress'
9
+ require 'shellwords'
10
+ require 'gdbm'
11
+ require 'digest'
12
+ require 'haml'
13
+
14
+ DIR = 'tmp'
15
+ Pathname(DIR).mkpath
16
+
17
+ Array.class_eval do
18
+ # For an array of arrays with possible values yields arrays with all
19
+ # combinations of values
20
+ #
21
+ # [[1, 2], 3, [4, 5]].variants{ |v| p v }
22
+ # # [1, 3, 4]
23
+ # # [1, 3, 5]
24
+ # # [2, 3, 4]
25
+ # # [2, 3, 5]
26
+ def variants(&block)
27
+ if block
28
+ if empty?
29
+ yield([])
30
+ else
31
+ head, *tail = map(&method(:Array))
32
+ head.product(*tail, &block)
33
+ end
34
+ self
35
+ else
36
+ enum_for(:variants)
37
+ end
38
+ end
39
+
40
+ # Sum elements or results of running block on elements
41
+ def sum(initial = 0, &block)
42
+ if block
43
+ reduce(initial){ |memo, item| memo + block[item] }
44
+ else
45
+ reduce(initial, :+)
46
+ end
47
+ end
48
+ end
49
+
50
+ Hash.class_eval do
51
+ # For a hash with arrays of possible values yields hashes with all
52
+ # combinations of keys mapped to value
53
+ #
54
+ # {:a => [1, 2], :b => 3, :c => [4, 5]}.variants{ |v| p v }
55
+ # # {:a=>1, :b=>3, :c=>4}
56
+ # # {:a=>1, :b=>3, :c=>5}
57
+ # # {:a=>2, :b=>3, :c=>4}
58
+ # # {:a=>2, :b=>3, :c=>5}
59
+ def variants
60
+ if block_given?
61
+ if empty?
62
+ yield({})
63
+ else
64
+ keys, values = to_a.transpose
65
+ values.variants do |variant|
66
+ yield Hash[keys.zip(variant)]
67
+ end
68
+ end
69
+ self
70
+ else
71
+ enum_for(:variants)
72
+ end
73
+ end
74
+ end
75
+
76
+ Process.times.class.class_eval do
77
+ def sum
78
+ utime + stime + cutime + cstime
79
+ end
80
+ end
81
+
82
+ ImageOptim::ImagePath.class_eval do
83
+ def shellescape
84
+ to_s.shellescape
85
+ end
86
+
87
+ def digest
88
+ @digest ||= Digest::SHA256.file(to_s).hexdigest
89
+ end
90
+
91
+ def cache_etag
92
+ [mtime, digest]
93
+ end
94
+ end
95
+
96
+ # Analyse efficency of workers
97
+ class Analyser
98
+ Cmd = ImageOptim::Cmd
99
+ HashHelpers = ImageOptim::HashHelpers
100
+
101
+ # Caching entries using GDBM
102
+ class Cache
103
+ PATH = "#{DIR}/worker-analysis.db"
104
+
105
+ class << self
106
+ def get(key, etag, &block)
107
+ if block
108
+ get!(key, etag) || set!(key, etag, &block)
109
+ else
110
+ get!(key, etag)
111
+ end
112
+ end
113
+
114
+ def set(key, etag, &block)
115
+ set!(key, etag, &block)
116
+ end
117
+
118
+ private
119
+
120
+ def open
121
+ GDBM.open(PATH) do |db|
122
+ yield db
123
+ end
124
+ end
125
+
126
+ def get!(key, etag)
127
+ raw = open{ |db| db[Marshal.dump(key)] }
128
+ return unless raw
129
+ entry = Marshal.load(raw)
130
+ return unless entry[1] == etag
131
+ entry[0]
132
+ end
133
+
134
+ def set!(key, etag, &block)
135
+ value = block.call
136
+ open{ |db| db[Marshal.dump(key)] = Marshal.dump([value, etag]) }
137
+ value
138
+ end
139
+ end
140
+ end
141
+
142
+ # Delegate to worker with short id
143
+ class WorkerVariant < DelegateClass(ImageOptim::Worker)
144
+ attr_reader :klass, :id
145
+ def initialize(klass, image_optim, options)
146
+ @klass = klass
147
+ @image_optim = image_optim
148
+ @id = "#{klass.bin_sym}#{options unless options.empty?}"
149
+ __setobj__(klass.new(image_optim, options))
150
+ end
151
+
152
+ def cache_etag
153
+ [
154
+ id,
155
+ bin_versions,
156
+ source_digest,
157
+ ]
158
+ end
159
+
160
+ private
161
+
162
+ def bin_versions
163
+ @bin_versions ||= used_bins.map do |name|
164
+ @image_optim.resolve_bin!(name).to_s
165
+ end
166
+ end
167
+
168
+ def source_digest
169
+ @digest ||= begin
170
+ source_path = __getobj__.method(:optimize).source_location[0]
171
+ Digest::SHA256.file(source_path).hexdigest
172
+ end
173
+ end
174
+ end
175
+
176
+ # One worker result
177
+ StepResult = Struct.new(*[
178
+ :worker_id,
179
+ :success,
180
+ :time,
181
+ :src_size,
182
+ :dst_size,
183
+ ]) do
184
+ def self.run(src, dst, worker)
185
+ start = Process.times.sum
186
+ success = worker.optimize(src, dst)
187
+ time = Process.times.sum - start
188
+
189
+ new(worker.id, success, time, src.size, success ? dst.size : nil)
190
+ end
191
+
192
+ def size
193
+ success ? dst_size : src_size
194
+ end
195
+
196
+ def inspect
197
+ "<S:#{worker_id} #{success ? '✓' : '✗'} #{time}s #{src_size}→#{dst_size}>"
198
+ end
199
+ end
200
+
201
+ # Chain of workers result
202
+ ChainResult = Struct.new(*[
203
+ :format,
204
+ :steps,
205
+ :difference,
206
+ ]) do
207
+ def worker_ids
208
+ steps.map(&:worker_id)
209
+ end
210
+
211
+ def time
212
+ steps.sum(&:time)
213
+ end
214
+
215
+ def src_size
216
+ steps.first.src_size
217
+ end
218
+
219
+ def dst_size
220
+ steps.last.size
221
+ end
222
+
223
+ def ratio
224
+ dst_size.to_f / src_size
225
+ end
226
+
227
+ def inspect
228
+ "<C #{src_size}→#{dst_size} %:#{difference} #{steps.inspect}>"
229
+ end
230
+ end
231
+
232
+ # Run all possible worker chains
233
+ class WorkerRunner
234
+ def initialize(path, workers)
235
+ @path = ImageOptim::ImagePath.convert(path)
236
+ @workers = workers
237
+ end
238
+
239
+ def results
240
+ cache_etag = [@path.cache_etag, @workers.map(&:cache_etag).sort]
241
+ Cache.get(@path.to_s, cache_etag) do
242
+ results = []
243
+ run_workers(@path, @workers){ |result| results << result }
244
+ run_cache.clear
245
+ results
246
+ end
247
+ end
248
+
249
+ private
250
+
251
+ def run_cache
252
+ @run_cache ||= Hash.new{ |h, k| h[k] = {} }
253
+ end
254
+
255
+ def with_progress(workers, last_result, &block)
256
+ if !last_result || last_result.steps.length < 3
257
+ workers.with_progress(&block)
258
+ else
259
+ workers.each(&block)
260
+ end
261
+ end
262
+
263
+ def run_workers(src, workers, last_result = nil, &block)
264
+ with_progress(workers, last_result) do |worker|
265
+ worker_result, result_image = run_worker(src, worker)
266
+
267
+ steps = (last_result ? last_result.steps : []) + [worker_result]
268
+ chain_result = ChainResult.new(src.format, steps)
269
+ chain_result.difference = difference_with(result_image)
270
+
271
+ block.call(chain_result)
272
+
273
+ workers_left = workers.reject{ |w| w.klass == worker.klass }
274
+ run_workers(result_image, workers_left, chain_result, &block)
275
+ end
276
+ end
277
+
278
+ def run_worker(src, worker)
279
+ run_cache[:run][[src.digest, worker.id]] ||= begin
280
+ dst = src.temp_path
281
+ worker_result = StepResult.run(src, dst, worker)
282
+ [worker_result, worker_result.success ? dst : src]
283
+ end
284
+ end
285
+
286
+ def difference_with(other)
287
+ run_cache[:difference][other.digest] ||= begin
288
+ images = [flatten_animation(@path), flatten_animation(other)]
289
+
290
+ alpha_presence = images.map do |image|
291
+ Cmd.capture("identify -format '%A' #{image.shellescape}")
292
+ end
293
+ if alpha_presence.uniq.length == 2
294
+ images.map!{ |image| underlay_noise(image) }
295
+ end
296
+
297
+ nrmse_command = %W[
298
+ convert
299
+ #{images[0]} -auto-orient
300
+ #{images[1]} -auto-orient
301
+ -metric RMSE
302
+ -compare
303
+ -format %[distortion]
304
+ info:
305
+ ].shelljoin
306
+ nrmse = Cmd.capture(nrmse_command).to_f
307
+ unless $CHILD_STATUS.success?
308
+ fail "failed comparison of #{@path} with #{other}"
309
+ end
310
+ nrmse
311
+ end
312
+ end
313
+
314
+ def flatten_animation(image)
315
+ run_cache[:flatten][image.digest] ||= begin
316
+ if image.format == :gif
317
+ flattened = image.temp_path
318
+ Cmd.run(*%W[
319
+ convert
320
+ #{image.shellescape}
321
+ -coalesce
322
+ -append
323
+ #{flattened.shellescape}
324
+ ])
325
+ unless $CHILD_STATUS.success?
326
+ fail "failed flattening of #{image}"
327
+ end
328
+ flattened
329
+ else
330
+ image
331
+ end
332
+ end
333
+ end
334
+
335
+ def underlay_noise(image)
336
+ run_cache[:noise][image.digest] ||= begin
337
+ with_noise = image.temp_path
338
+ Cmd.run(*%W[
339
+ convert
340
+ #{image.shellescape}
341
+ +noise Random
342
+ #{image.shellescape}
343
+ -flatten
344
+ -alpha off
345
+ #{with_noise.shellescape}
346
+ ])
347
+ unless $CHILD_STATUS.success?
348
+ fail "failed underlaying noise to #{image}"
349
+ end
350
+ with_noise
351
+ end
352
+ end
353
+ end
354
+
355
+ # Helper for producing statistics
356
+ class Stats
357
+ # Calculate statistics for chain
358
+ class Chain
359
+ attr_reader :worker_stats
360
+ attr_reader :unused_workers
361
+ attr_reader :entry_count
362
+ attr_reader :original_size, :optimized_size, :ratio, :avg_ratio
363
+ attr_reader :avg_difference, :max_difference, :warn_level
364
+ attr_reader :time, :speed
365
+
366
+ def initialize(worker_ids, results)
367
+ steps_by_worker_id = results.flat_map(&:steps).group_by(&:worker_id)
368
+ @worker_stats = worker_ids.map do |worker_id|
369
+ Worker.new(worker_id, steps_by_worker_id[worker_id])
370
+ end
371
+ @unused_workers = worker_stats.any?(&:unused?)
372
+
373
+ @entry_count = results.count
374
+ @original_size = results.sum(&:src_size)
375
+ @optimized_size = results.sum(&:dst_size)
376
+ @ratio = optimized_size.to_f / original_size
377
+ @avg_ratio = results.sum(&:ratio) / results.length
378
+ @avg_difference = results.sum(&:difference) / results.length
379
+ @max_difference = results.map(&:difference).max
380
+ @warn_level = case
381
+ when max_difference >= 0.1 then 'high'
382
+ when max_difference >= 0.01 then 'medium'
383
+ when max_difference >= 0.001 then 'low'
384
+ end
385
+ @time = results.sum(&:time)
386
+ @speed = case
387
+ when time > 0 then (original_size - optimized_size) / time
388
+ when original_size == optimized_size then 0
389
+ else 1.0 / 0.0
390
+ end
391
+ end
392
+ end
393
+
394
+ # Worker usage
395
+ class Worker
396
+ attr_reader :name
397
+ attr_reader :success_count
398
+ def initialize(name, steps)
399
+ @name = name
400
+ @success_count = steps.count(&:success)
401
+ end
402
+
403
+ def unused?
404
+ success_count.zero?
405
+ end
406
+ end
407
+
408
+ attr_reader :name, :results
409
+ def initialize(name, results)
410
+ @name = name.to_s
411
+ @results = results
412
+ end
413
+
414
+ def each_chain(&block)
415
+ chains = results.group_by(&:worker_ids).map do |worker_ids, results|
416
+ Chain.new(worker_ids, results)
417
+ end
418
+ chains.sort_by!{ |chain| [chain.optimized_size, chain.time] }
419
+ chains.each(&block)
420
+ end
421
+ end
422
+
423
+ def initialize(option_variants)
424
+ option_variants = HashHelpers.deep_symbolise_keys(option_variants)
425
+ image_optim = ImageOptim.new
426
+
427
+ @workers_by_format = Hash.new{ |h, k| h[k] = [] }
428
+ ImageOptim::Worker.klasses.each do |klass|
429
+ worker_options_config = option_variants.delete(klass.bin_sym) || {}
430
+ worker_option_variants = case worker_options_config
431
+ when Array
432
+ worker_options_config
433
+ when Hash
434
+ worker_options_config.variants
435
+ else
436
+ fail "Array or Hash expected, got #{worker_options_config}"
437
+ end
438
+ worker_option_variants.each do |options|
439
+ options = HashHelpers.deep_symbolise_keys(options)
440
+ worker = WorkerVariant.new(klass, image_optim, options)
441
+ puts worker.id
442
+ worker.image_formats.each do |format|
443
+ @workers_by_format[format] << worker
444
+ end
445
+ end
446
+ end
447
+
448
+ fail "unknown variants: #{option_variants}" unless option_variants.empty?
449
+ end
450
+
451
+ def analyse(paths)
452
+ results = process_paths(paths).shuffle.with_progress.flat_map do |path|
453
+ WorkerRunner.new(path, workers_for_image(path)).results
454
+ end
455
+
456
+ template = Haml::Engine.new(File.read("#{__FILE__}.haml"))
457
+ by_format = results.group_by(&:format)
458
+ formats = by_format.keys.sort
459
+ basenames = Hash[formats.map do |format|
460
+ [format, "worker-analysis-#{format}.html"]
461
+ end]
462
+ formats.each do |format|
463
+ stats = Stats.new('all', by_format[format])
464
+ model = {
465
+ :stats_format => format,
466
+ :stats => stats,
467
+ :format_links => basenames,
468
+ }
469
+ html = template.render(nil, model)
470
+ path = FSPath("#{DIR}/#{basenames[format]}")
471
+ path.write(html)
472
+ puts "Created #{path}"
473
+ end
474
+ end
475
+
476
+ private
477
+
478
+ def process_paths(paths)
479
+ paths = paths.map{ |path| ImageOptim::ImagePath.convert(path) }
480
+ paths.select!{ |path| path.exist? || warn("#{path} doesn't exits") }
481
+ paths.select!{ |path| path.file? || warn("#{path} is not a file") }
482
+ paths.select!{ |path| path.format || warn("#{path} is not an image") }
483
+ paths.select! do |path|
484
+ workers_for_image(path) || warn("#{path} can't be handled by any worker")
485
+ end
486
+ paths
487
+ end
488
+
489
+ def workers_for_image(path)
490
+ @workers_by_format[ImageOptim::ImagePath.convert(path).format]
491
+ end
492
+ end
493
+
494
+ def option_variants
495
+ path = '.analysis_variants.yml'
496
+ case h = YAML.load_file(path)
497
+ when Hash then h
498
+ when false then {}
499
+ else abort "expected a hash in #{path}"
500
+ end
501
+ rescue Errno::ENOENT => e
502
+ warn e
503
+ {}
504
+ end
505
+
506
+ analyser = Analyser.new(option_variants)
507
+
508
+ if ARGV.empty?
509
+ abort <<-HELP
510
+ Specify paths for analysis.
511
+
512
+ Example of `.analysis_variants.yml`:
513
+ jpegtran: # 3 worker variants
514
+ - jpegrescan: true
515
+ - progressive: true
516
+ - progressive: false
517
+ optipng: # 6 worker variants by combining options
518
+ level: [6, 7]
519
+ interlace: [true, false, nil]
520
+ # other workers will be used with default options
521
+ HELP
522
+ end
523
+ analyser.analyse(ARGV)