image_optim 0.17.1 → 0.18.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.
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)