image_optim 0.20.2 → 0.21.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.
@@ -9,7 +9,7 @@ README_FILE = File.expand_path('../../README.markdown', __FILE__)
9
9
  BEGIN_MARKER = '<!---<worker-options>-->'
10
10
  END_MARKER = '<!---</worker-options>-->'
11
11
  GENERATED_NOTE = '<!-- markdown for worker options is generated by '\
12
- "`#{$PROGRAM_NAME}` -->"
12
+ "`#{Pathname($PROGRAM_NAME).cleanpath}` -->"
13
13
 
14
14
  def write_worker_options(io, klass)
15
15
  io.puts "### :#{klass.bin_sym} =>"
@@ -9,7 +9,8 @@ require 'progress'
9
9
  require 'shellwords'
10
10
  require 'gdbm'
11
11
  require 'digest'
12
- require 'haml'
12
+ require 'erb'
13
+ require 'ostruct'
13
14
 
14
15
  DIR = 'tmp'
15
16
  Pathname(DIR).mkpath
@@ -88,7 +89,7 @@ ImageOptim::ImagePath.class_eval do
88
89
  @digest ||= Digest::SHA256.file(to_s).hexdigest
89
90
  end
90
91
 
91
- def cache_etag
92
+ def etag
92
93
  [mtime, digest]
93
94
  end
94
95
  end
@@ -100,31 +101,26 @@ class Analyser
100
101
 
101
102
  # Caching entries using GDBM
102
103
  class Cache
103
- PATH = "#{DIR}/worker-analysis.db"
104
+ DB = GDBM.new("#{DIR}/worker-analysis.db")
104
105
 
105
106
  class << self
106
- def get(key, etag, &block)
107
+ def get(context, key, etag, &block)
108
+ full_key = [context, key]
107
109
  if block
108
- get!(key, etag) || set!(key, etag, &block)
110
+ get!(full_key, etag) || set!(full_key, etag, &block)
109
111
  else
110
- get!(key, etag)
112
+ get!(full_key, etag)
111
113
  end
112
114
  end
113
115
 
114
- def set(key, etag, &block)
115
- set!(key, etag, &block)
116
+ def set(context, key, etag, &block)
117
+ set!([context, key], etag, &block)
116
118
  end
117
119
 
118
120
  private
119
121
 
120
- def open
121
- GDBM.open(PATH) do |db|
122
- yield db
123
- end
124
- end
125
-
126
122
  def get!(key, etag)
127
- raw = open{ |db| db[Marshal.dump(key)] }
123
+ raw = DB[Marshal.dump(key)]
128
124
  return unless raw
129
125
  entry = Marshal.load(raw)
130
126
  return unless entry[1] == etag
@@ -133,7 +129,7 @@ class Analyser
133
129
 
134
130
  def set!(key, etag, &block)
135
131
  value = block.call
136
- open{ |db| db[Marshal.dump(key)] = Marshal.dump([value, etag]) }
132
+ DB[Marshal.dump(key)] = Marshal.dump([value, etag])
137
133
  value
138
134
  end
139
135
  end
@@ -141,19 +137,17 @@ class Analyser
141
137
 
142
138
  # Delegate to worker with short id
143
139
  class WorkerVariant < DelegateClass(ImageOptim::Worker)
144
- attr_reader :cons_id, :id
140
+ attr_reader :name, :id, :cons_id
145
141
  def initialize(klass, image_optim, options)
146
142
  allow_consecutive_on = Array(options.delete(:allow_consecutive_on))
147
143
  @image_optim = image_optim
148
- @id = klass.bin_sym.to_s
149
- unless options.empty?
150
- @id << "(#{options.map{ |k, v| "#{k}:#{v.inspect}" }.join(', ')})"
151
- end
144
+ @name = klass.bin_sym.to_s + options_string(options)
152
145
  __setobj__(klass.new(image_optim, options))
146
+ @id = klass.bin_sym.to_s + options_string(self.options)
153
147
  @cons_id = [klass, allow_consecutive_on.map{ |key| [key, send(key)] }]
154
148
  end
155
149
 
156
- def cache_etag
150
+ def etag
157
151
  [
158
152
  id,
159
153
  bin_versions,
@@ -175,6 +169,11 @@ class Analyser
175
169
  Digest::SHA256.file(source_path).hexdigest
176
170
  end
177
171
  end
172
+
173
+ def options_string(options)
174
+ return '' if options.empty?
175
+ "(#{options.sort.map{ |k, v| "#{k}:#{v.inspect}" }.join(', ')})"
176
+ end
178
177
  end
179
178
 
180
179
  # One worker result
@@ -184,19 +183,35 @@ class Analyser
184
183
  :time,
185
184
  :src_size,
186
185
  :dst_size,
186
+ :cache,
187
187
  ]) do
188
- def self.run(src, dst, worker)
188
+ def self.run(src, worker)
189
+ dst = src.temp_path
189
190
  start = Process.times.sum
190
191
  success = worker.optimize(src, dst)
191
192
  time = Process.times.sum - start
192
193
 
193
- new(worker.id, success, time, src.size, success ? dst.size : nil)
194
+ dst_size = success ? dst.size : nil
195
+ cache = (success ? dst : src).digest.sub(/../, '\0/') + ".#{src.format}"
196
+ result = new(worker.id, success, time, src.size, dst_size, cache)
197
+ if success
198
+ path = result.path
199
+ unless path.exist?
200
+ path.dirname.mkpath
201
+ dst.rename(path)
202
+ end
203
+ end
204
+ result
194
205
  end
195
206
 
196
207
  def size
197
208
  success ? dst_size : src_size
198
209
  end
199
210
 
211
+ def path
212
+ ImageOptim::ImagePath.convert("#{DIR}/worker-analysis/#{cache}")
213
+ end
214
+
200
215
  def inspect
201
216
  "<S:#{worker_id} #{success ? '✓' : '✗'} #{time}s #{src_size}→#{dst_size}>"
202
217
  end
@@ -241,13 +256,10 @@ class Analyser
241
256
  end
242
257
 
243
258
  def results
244
- cache_etag = [@path.cache_etag, @workers.map(&:cache_etag).sort]
245
- Cache.get(@path.to_s, cache_etag) do
246
- results = []
247
- run_workers(@path, @workers){ |result| results << result }
248
- run_cache.clear
249
- results
250
- end
259
+ results = []
260
+ run_workers(@path, @workers){ |result| results << result }
261
+ run_cache.clear
262
+ results
251
263
  end
252
264
 
253
265
  private
@@ -274,21 +286,29 @@ class Analyser
274
286
 
275
287
  block.call(chain_result)
276
288
 
277
- workers_left = workers.reject{ |w| w.cons_id == worker.cons_id }
289
+ workers_left = workers.reject do |w|
290
+ w.cons_id == worker.cons_id || w.run_order < worker.run_order
291
+ end
278
292
  run_workers(result_image, workers_left, chain_result, &block)
279
293
  end
280
294
  end
281
295
 
282
296
  def run_worker(src, worker)
283
297
  run_cache[:run][[src.digest, worker.id]] ||= begin
284
- dst = src.temp_path
285
- worker_result = StepResult.run(src, dst, worker)
286
- [worker_result, worker_result.success ? dst : src]
298
+ cache_args = [:result, [src.digest, worker.id], worker.etag]
299
+ result = Cache.get(*cache_args)
300
+ if !result || (result.success && !result.path.exist?)
301
+ result = Cache.set(*cache_args) do
302
+ StepResult.run(src, worker)
303
+ end
304
+ end
305
+ [result, result.success ? result.path : src]
287
306
  end
288
307
  end
289
308
 
290
309
  def difference_with(other)
291
- run_cache[:difference][other.digest] ||= begin
310
+ run_cache[:difference][other.digest] ||=
311
+ Cache.get(:difference, [@path.digest, other.digest].sort, nil) do
292
312
  images = [flatten_animation(@path), flatten_animation(other)]
293
313
 
294
314
  alpha_presence = images.map do |image|
@@ -365,13 +385,10 @@ class Analyser
365
385
  attr_reader :entry_count
366
386
  attr_reader :original_size, :optimized_size, :ratio, :avg_ratio
367
387
  attr_reader :avg_difference, :max_difference, :warn_level
368
- attr_reader :time, :speed
388
+ attr_reader :time, :avg_time, :speed
369
389
 
370
- def initialize(worker_ids, results)
371
- steps_by_worker_id = results.flat_map(&:steps).group_by(&:worker_id)
372
- @worker_stats = worker_ids.map do |worker_id|
373
- Worker.new(worker_id, steps_by_worker_id[worker_id])
374
- end
390
+ def initialize(worker_ids, results, ids2names)
391
+ @worker_stats = build_worker_stats(worker_ids, results, ids2names)
375
392
  @unused_workers = worker_stats.any?(&:unused?)
376
393
 
377
394
  @entry_count = results.count
@@ -382,6 +399,7 @@ class Analyser
382
399
  @avg_difference = results.sum(&:difference) / results.length
383
400
  @max_difference = results.map(&:difference).max
384
401
  @time = results.sum(&:time)
402
+ @avg_time = time / results.length
385
403
 
386
404
  @warn_level = calculate_warn_level
387
405
  @speed = calculate_speed
@@ -389,6 +407,14 @@ class Analyser
389
407
 
390
408
  private
391
409
 
410
+ def build_worker_stats(worker_ids, results, ids2names)
411
+ steps_by_worker_id = results.flat_map(&:steps).group_by(&:worker_id)
412
+ worker_ids.map do |worker_id|
413
+ worker_name = ids2names[worker_id] || worker_id
414
+ Worker.new(worker_name, steps_by_worker_id[worker_id])
415
+ end
416
+ end
417
+
392
418
  def calculate_warn_level
393
419
  case
394
420
  when max_difference >= 0.1 then 'high'
@@ -410,9 +436,12 @@ class Analyser
410
436
  class Worker
411
437
  attr_reader :name
412
438
  attr_reader :success_count
439
+ attr_reader :time, :avg_time
413
440
  def initialize(name, steps)
414
441
  @name = name
415
442
  @success_count = steps.count(&:success)
443
+ @time = steps.sum(&:time)
444
+ @avg_time = time / steps.length
416
445
  end
417
446
 
418
447
  def unused?
@@ -420,15 +449,16 @@ class Analyser
420
449
  end
421
450
  end
422
451
 
423
- attr_reader :name, :results
424
- def initialize(name, results)
452
+ attr_reader :name, :results, :ids2names
453
+ def initialize(name, results, ids2names)
425
454
  @name = name.to_s
426
455
  @results = results
456
+ @ids2names = ids2names
427
457
  end
428
458
 
429
459
  def each_chain(&block)
430
460
  chains = results.group_by(&:worker_ids).map do |worker_ids, results|
431
- Chain.new(worker_ids, results)
461
+ Chain.new(worker_ids, results, ids2names)
432
462
  end
433
463
  chains.sort_by!{ |chain| [chain.optimized_size, chain.time] }
434
464
  chains.each(&block)
@@ -455,36 +485,36 @@ class Analyser
455
485
  options = HashHelpers.deep_symbolise_keys(options)
456
486
  options[:allow_consecutive_on] = allow_consecutive_on
457
487
  worker = WorkerVariant.new(klass, image_optim, options)
458
- puts worker.id
459
488
  worker.image_formats.each do |format|
460
489
  @workers_by_format[format] << worker
461
490
  end
462
491
  end
463
492
  end
464
493
 
494
+ log_workers_by_format
495
+
465
496
  fail "unknown variants: #{option_variants}" unless option_variants.empty?
466
497
  end
467
498
 
468
499
  def analyse(paths)
469
- results = process_paths(paths).shuffle.with_progress.flat_map do |path|
470
- WorkerRunner.new(path, workers_for_image(path)).results
471
- end
500
+ results = collect_results(paths)
472
501
 
473
- template = Haml::Engine.new(File.read("#{__FILE__}.haml"))
502
+ template = ERB.new(template_path.read, nil, '>')
474
503
  by_format = results.group_by(&:format)
475
504
  formats = by_format.keys.sort
476
505
  basenames = Hash[formats.map do |format|
477
506
  [format, "worker-analysis-#{format}.html"]
478
507
  end]
479
508
  formats.each do |format|
480
- stats = Stats.new('all', by_format[format])
509
+ stats = Stats.new('all', by_format[format], worker_ids2names)
510
+ path = FSPath("#{DIR}/#{basenames[format]}")
481
511
  model = {
482
512
  :stats_format => format,
483
513
  :stats => stats,
484
514
  :format_links => basenames,
515
+ :template_dir => template_path.dirname.relative_path_from(path.dirname),
485
516
  }
486
- html = template.render(nil, model)
487
- path = FSPath("#{DIR}/#{basenames[format]}")
517
+ html = template.result(OpenStruct.new(model).instance_eval{ binding })
488
518
  path.write(html)
489
519
  puts "Created #{path}"
490
520
  end
@@ -492,6 +522,18 @@ class Analyser
492
522
 
493
523
  private
494
524
 
525
+ def worker_ids2names
526
+ Hash[@workers_by_format.values.flatten.map do |worker|
527
+ [worker.id, worker.name]
528
+ end]
529
+ end
530
+
531
+ def collect_results(paths)
532
+ process_paths(paths).shuffle.with_progress.flat_map do |path|
533
+ WorkerRunner.new(path, workers_for_image(path)).results
534
+ end
535
+ end
536
+
495
537
  def process_paths(paths)
496
538
  paths = paths.map{ |path| ImageOptim::ImagePath.convert(path) }
497
539
  paths.select!{ |path| path.exist? || warn("#{path} doesn't exits") }
@@ -506,6 +548,19 @@ private
506
548
  def workers_for_image(path)
507
549
  @workers_by_format[ImageOptim::ImagePath.convert(path).format]
508
550
  end
551
+
552
+ def log_workers_by_format
553
+ @workers_by_format.each do |format, workers|
554
+ puts "#{format}:"
555
+ workers.sort_by.with_index{ |w, i| [w.run_order, i] }.each do |worker|
556
+ puts " #{worker.name} [#{worker.run_order}]"
557
+ end
558
+ end
559
+ end
560
+
561
+ def template_path
562
+ FSPath("#{File.dirname(__FILE__)}/template/#{File.basename(__FILE__)}.erb")
563
+ end
509
564
  end
510
565
 
511
566
  def option_variants
@@ -91,9 +91,9 @@ describe ImageOptim::Config do
91
91
  expect(config.for_worker(Abc)).to eq(:option => true)
92
92
  end
93
93
 
94
- it 'returns passed false' do
94
+ it 'returns {:disable => true} for false' do
95
95
  config = IOConfig.new(:abc => false)
96
- expect(config.for_worker(Abc)).to eq(false)
96
+ expect(config.for_worker(Abc)).to eq(:disable => true)
97
97
  end
98
98
 
99
99
  it 'raises on unknown option' do
@@ -0,0 +1,121 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'ImageOptim::Railtie' do
4
+ require 'rails/all'
5
+ require 'image_optim/railtie'
6
+
7
+ def init_rails_app
8
+ Class.new(Rails::Application) do
9
+ # Rails 4 requires application class to have name
10
+ def self.name
11
+ 'Dummy'
12
+ end
13
+
14
+ config.active_support.deprecation = :stderr
15
+ config.eager_load = false
16
+
17
+ config.logger = Logger.new('/dev/null')
18
+
19
+ config.assets.tap do |assets|
20
+ assets.enabled = true
21
+ assets.version = '1.0'
22
+ assets.cache_store = :null_store
23
+ assets.paths = %w[spec/images]
24
+
25
+ assets.delete(:compress)
26
+ assets.delete(:image_optim)
27
+ end
28
+
29
+ yield config if block_given?
30
+ end.initialize!
31
+ end
32
+
33
+ after do
34
+ Rails.application = nil
35
+ end
36
+
37
+ describe :initialization do
38
+ it 'initializes by default' do
39
+ expect(ImageOptim).to receive(:new)
40
+ init_rails_app
41
+ end
42
+
43
+ it 'initializes if config.assets.image_optim is nil' do
44
+ expect(ImageOptim).to receive(:new)
45
+ init_rails_app do |config|
46
+ config.assets.image_optim = nil
47
+ end
48
+ end
49
+
50
+ it 'does not initialize if config.assets.image_optim is false' do
51
+ expect(ImageOptim).not_to receive(:new)
52
+ init_rails_app do |config|
53
+ config.assets.image_optim = false
54
+ end
55
+ end
56
+
57
+ it 'does not initialize if config.assets.compress is false' do
58
+ expect(ImageOptim).not_to receive(:new)
59
+ init_rails_app do |config|
60
+ config.assets.compress = false
61
+ end
62
+ end
63
+
64
+ describe 'options' do
65
+ it 'initializes with empty hash by default' do
66
+ expect(ImageOptim).to receive(:new).with({})
67
+ init_rails_app
68
+ end
69
+
70
+ it 'initializes with empty hash if config.assets.image_optim is true' do
71
+ expect(ImageOptim).to receive(:new).with({}).and_call_original
72
+ init_rails_app do |config|
73
+ config.assets.image_optim = true
74
+ end
75
+ end
76
+
77
+ it 'initializes with empty hash if config.assets.image_optim is nil' do
78
+ expect(ImageOptim).to receive(:new).with({}).and_call_original
79
+ init_rails_app do |config|
80
+ config.assets.image_optim = nil
81
+ end
82
+ end
83
+
84
+ it 'initializes with hash assigned to config.assets.image_optim' do
85
+ hash = double
86
+ expect(ImageOptim).to receive(:new).with(hash)
87
+ init_rails_app do |config|
88
+ config.assets.image_optim = hash
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ describe :assets do
95
+ before do
96
+ stub_const('ImagePath', ImageOptim::ImagePath)
97
+ end
98
+
99
+ %w[
100
+ icecream.gif
101
+ lena.jpg
102
+ rails.png
103
+ test.svg
104
+ ].each do |asset_name|
105
+ it "optimizes #{asset_name}" do
106
+ asset = init_rails_app.assets.find_asset(asset_name)
107
+
108
+ asset_data = asset.source
109
+ original = ImagePath.convert(asset.pathname)
110
+
111
+ expect(asset_data).to be_smaller_than(original)
112
+
113
+ ImagePath.temp_file_path %W[spec .#{original.format}] do |temp|
114
+ temp.write(asset_data)
115
+
116
+ expect(temp).to be_similar_to(original, 0)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end if ENV['RAILS_VERSION']