Created
June 7, 2012 21:40
-
-
Save JamesHarrison/2891708 to your computer and use it in GitHub Desktop.
A simple script that optimises images in the current folder using optipng, gifsicle and jpegoptim, plus exiftool for metadata
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# Recursively optimises in-place all images in a directory tree, starting in the current folder or a folder specified on the command line | |
# Dependencies: | |
# * optipng | |
# * gifscicle | |
# * jpegoptim | |
# * exiftool | |
# Recursively optimises a given path | |
def recursively_optimise(path,dt,depth=0) | |
raise ArgumentError, "Argument must be a directory" unless File.directory?(path) | |
puts "Called with #{path}" | |
total_before = 0 | |
total_after = 0 | |
total_before_types = {} | |
total_after_types = {} | |
total_time_types = {} | |
begin | |
# Call ourself on any directories in this path | |
Dir.foreach(path) do |entry| | |
next if entry == "." | |
next if entry == ".." | |
fullpath = File.join(path, entry) | |
if File.directory?(fullpath) | |
res = recursively_optimise(fullpath, dt, (depth+1)) | |
total_before += res[:before] | |
total_after += res[:after] | |
#if res[:before_types] and res[:after_types] | |
res[:before_types].each_pair do |k,v| | |
total_before_types[k] = 0 unless total_before_types[k] | |
total_before_types[k] += v | |
end | |
res[:after_types].each_pair do |k,v| | |
total_after_types[k] = 0 unless total_after_types[k] | |
total_after_types[k] += v | |
end | |
res[:time_types].each_pair do |k,v| | |
total_time_types[k] = 0 unless total_time_types[k] | |
total_time_types[k] += v | |
end | |
#end | |
else | |
res = optimise_image(fullpath, dt) | |
total_before += res[:before] | |
total_after += res[:after] | |
total_before_types[res[:type]] = 0 unless total_before_types[res[:type]] | |
total_before_types[res[:type]] += res[:before] | |
total_after_types[res[:type]] = 0 unless total_after_types[res[:type]] | |
total_after_types[res[:type]] += res[:after] | |
total_time_types[res[:type]] = 0 unless total_time_types[res[:type]] | |
total_time_types[res[:type]] += res[:time] | |
end | |
end | |
rescue Exception => e | |
dt.write_to_disk | |
puts "Got exception #{e.inspect} while processing!" | |
puts e.backtrace | |
end | |
if depth == 0 | |
puts "\n\nDone optimising everything!" | |
puts "I started with a total size of #{total_before.to_human}" | |
puts "I finished with a total size of #{total_after.to_human}, a reduction of #{(total_before-total_after).abs.to_human}" | |
puts"\nType breakdowns:" | |
total_before_types.each_pair do |k,v| | |
a = total_after_types[k] | |
t = total_time_types[k] | |
puts "#{k.to_s.upcase}: #{v.to_human} before, #{a.to_human} after, reduction of #{(v-a).abs.to_human} or #{(((v-a).abs)/t.abs).to_human} per second saved" | |
end | |
end | |
dt.write_to_disk | |
return {:before => total_before, :after => total_after, :before_types => total_before_types, :after_types => total_after_types, :time_types => total_time_types} | |
end | |
# Optimises a single image | |
def optimise_image(path,dt) | |
# Tweak commands here if you want to. Defaults are needlessly overzealous and will take ages, especially PNG. | |
png_cmd = "optipng -i0 -fix -o7 -preserve" | |
gif_cmd = "gifsicle -O3" | |
jpg_cmd = "jpegoptim --max=95 -p" | |
start = Time.now | |
start_size = File.size(path) | |
if dt.is_done?(path) | |
puts "Skipping image at #{path}, already done" | |
else | |
puts "Optimising image at #{path}, start filesize #{start_size.to_human}" | |
# let's figure out what we've got | |
ext = File.extname(path).downcase | |
type = :unknown | |
if ext == ".png" | |
`#{png_cmd} "#{path}"` | |
type = :png | |
dt.mark_done(path) | |
elsif ext == ".gif" | |
type = :gif | |
# ooh, okay, so if we're a gif, are we animated? | |
eto = `exiftool "#{path}"` | |
et = eto.split("\n") | |
fc = et.detect{|l|l.include?("Frame Count")} | |
if fc | |
if (fc.split(":")[1].to_i rescue 1) > 0 | |
# We have more than one frame! We're animated or strange. gifsicle. | |
`#{gif_cmd} "#{path}"` | |
dt.mark_done(path) | |
else | |
# We're single frame, PNG probably does better | |
`#{png_cmd} "#{path}"` | |
pngpath = path.gsub(File.extname(path),".png") | |
if File.size(path) > File.size(pngpath) | |
# We're done! Nuke the old file | |
File.delete(path) | |
# Changed format, so update path | |
path = pngpath | |
dt.mark_done(path) | |
else | |
# Clean up the PNG we tried and gifsicle it. | |
File.delete(path.gsub(File.extname(path),".png")) | |
`#{gif_cmd} "#{path}"` | |
dt.mark_done(path) | |
end | |
end | |
else | |
# If we have no frame count data, assume not animated | |
`#{png_cmd} "#{path}"` | |
pngpath = path.gsub(File.extname(path),".png") | |
if File.size(path) > File.size(pngpath) | |
# We're done! Nuke the old file | |
File.delete(path) | |
# Changed format, so update path | |
path = pngpath | |
dt.mark_done(path) | |
else | |
# Clean up the PNG we tried and gifsicle it. | |
File.delete(path.gsub(File.extname(path),".png")) | |
`#{gif_cmd} "#{path}"` | |
dt.mark_done(path) | |
end | |
end | |
elsif ext == ".jpg" or ext == ".jpeg" | |
type = :jpg | |
`#{jpg_cmd} "#{path}"` | |
dt.mark_done(path) | |
else | |
puts "Skipped file, not a recognised file type" | |
end | |
end | |
return {:before=>start_size, :after=>File.size(path), :type=>type, :time=>(Time.now-start)} | |
end | |
class Numeric | |
def to_human | |
units = %w{B KB MB GB TB} | |
if self > 0 | |
e = (Math.log(self)/Math.log(1024)).floor | |
s = "%.3f" % (to_f / 1024**e) | |
return s.sub(/\.?0*$/, units[e]) | |
else | |
return "0 B" | |
end | |
end | |
end | |
class DoneTracker | |
def initialize | |
@path = File.expand_path("~/.optimdone.dat") | |
@done = [] | |
File.open(@path,'r'){|f|@done = f.read.split("\n")} rescue nil | |
puts "Loaded #{@done.size} entries that we have already optimised" | |
#puts "#{@done.inspect}" | |
end | |
def mark_done(path) | |
cleanpath = path.gsub("\n","").gsub("\r","").chomp | |
unless @done.include?(cleanpath) | |
@done << path | |
end | |
end | |
def is_done?(path) | |
cleanpath = path.gsub("\n","").gsub("\r","").chomp | |
@done.include?(cleanpath) | |
end | |
def write_to_disk | |
#puts "Writing #{@done.inspect}" | |
File.open(@path,'w'){|f|f<<@done.join("\n")} | |
puts "Wrote #{@done.size} entries to disk" | |
end | |
end | |
dt = DoneTracker.new | |
start = Time.now | |
res = nil | |
if ARGV[0] | |
res = recursively_optimise(ARGV[0],dt) | |
else | |
res = recursively_optimise(Dir.getwd,dt) | |
end | |
p res | |
finish = Time.now | |
puts "Finished in #{finish-start} seconds, or approximately saving #{((res[:before]-res[:after]).abs/(finish-start).abs).to_human} per second" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment