From 03fa819716d8c1d4f226e813c2becb95d6fd1cf0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 1 Feb 2022 10:34:41 -0500 Subject: [PATCH] initial commit --- .gitignore | 1 + README.md | 19 ++++++ go.mod | 3 + main.go | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f49f692 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +imageoptimizer diff --git a/README.md b/README.md new file mode 100644 index 0000000..419ef43 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# imageoptimizer + +imageoptimizer is a tool for bulk compression og JPG images. It will re-compress original images and replace them if the savings is +more than the requested percentage difference, default is 25%. By defaul it operated in a "dry run" mode where images are not replaced +but individual and total savings are reported at the end, unless you supply the '-replace' flag. + +# usage + +Recursively check all images starting from the current path. Calculate actions and total svings but don't make any changes. + +``` +imageoptimizer -recursive -compresslevel 85 -diff 25 +``` + +Replace images in the current path with a compressed version if compressing at 80% reduces file size by at least 30% + +``` +imageoptimizer -compresslevel --replace --diff 30 +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..59e5cfb --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.teamworkapps.com/shortcut/imageoptimizer + +go 1.17 diff --git a/main.go b/main.go new file mode 100644 index 0000000..03a35d3 --- /dev/null +++ b/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "flag" + "fmt" + "image/jpeg" + "io/fs" + "os" + "path/filepath" + "strings" +) + +type config struct { + Recursive bool + Diff uint + CompressLevel uint + Replace bool + StartingPath string + Quiet bool +} + +type stats struct { + FilesProcessed uint64 + FilesReplaced uint64 + StartingBytes uint64 + EndingBytes uint64 +} + +func main() { + + var c config + flag.BoolVar(&c.Recursive, "recursive", false, "Process folders recursively (default false).") + flag.UintVar(&c.Diff, "diff", 25, "Percent difference required to replace original image.") + flag.UintVar(&c.CompressLevel, "compresslevel", 85, "JPG compression level.") + flag.BoolVar(&c.Quiet, "quiet", false, "Less output - don't print per-file detail") + flag.StringVar(&c.StartingPath, "startingpath", ".", "Start from this path instead of current working dir") + flag.BoolVar(&c.Replace, "replace", false, "Replace with compressed versions if criteria are met. Otheriwse, just test and report, don't replace any images. (default false)") + flag.Parse() + + fail := false + if c.Diff > 99 { + fmt.Println("Fatal: diff out of range (-99)") + fail = true + } + if c.CompressLevel > 99 { + fmt.Println("Fatal: compresslevel out of range (1-99)") + fail = true + } + s, err := os.Stat(c.StartingPath) + if err != nil || !s.IsDir() { + fmt.Println("Starting path is invalid") + fail = true + } + if fail { + flag.Usage() + return + } + + // Generate list of files to examing + var filelist []string + if !c.Recursive { + d, err := os.ReadDir(c.StartingPath) + if err != nil { + fmt.Printf("Error reading path: %s", err.Error()) + } + for _, f := range d { + if !f.IsDir() { + suf := strings.ToLower(filepath.Ext(f.Name())) + if suf == ".jpg" || suf == ".jpeg" { + filelist = append(filelist, f.Name()) + } + + } + } + } else { + filepath.WalkDir(c.StartingPath, func(path string, d fs.DirEntry, err error) error { + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".jpg" || ext == ".jpeg" { + filelist = append(filelist, path) + } + return nil + }) + } + var st stats + for _, f := range filelist { + st = doSingleImage(f, c, st) + } + fmt.Println("\nEnding stats:") + fmt.Printf("Files processed: %d\nFiles replaced: %d\nStarting Size: %s (%d bytes)\nEnding Size: %s (%d bytes)\n", + st.FilesProcessed, st.FilesReplaced, SIFormat(st.StartingBytes), st.StartingBytes, SIFormat(st.EndingBytes), st.EndingBytes) + decrease:=st.StartingBytes - st.EndingBytes + diff := uint((float64(decrease) / float64(st.StartingBytes)) * 100) + + fmt.Printf("Total savings %s (%d bytes) or %d%%\n", SIFormat(decrease), decrease, diff ) + +} + +// doSingleImage processes a single file and updateds the run stats as it goes +func doSingleImage(f string, c config, s stats) stats { + st, err := os.Stat(f) + if err != nil { + fmt.Printf("Error reading file %s: %s\n", f, err.Error()) + return s + } + in, err := os.Open(f) + if err != nil { + fmt.Printf("Error opening file %s: %s\n", f, err.Error()) + } + defer in.Close() + orig, err := jpeg.Decode(in) + if err != nil { + fmt.Printf("Couldn't decode source image %s: %s\n", f, err.Error()) + return s + } + in.Close() + out, err := os.OpenFile("imageoptimizer.tmp", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, st.Mode()) + if err != nil { + fmt.Printf("Couldn't open out temprary file: %s\n", err.Error()) + return s + } + err = jpeg.Encode(out, orig, &jpeg.Options{Quality: int(c.CompressLevel)}) + if err != nil { + fmt.Printf("Error re-encoding image %s: %s\n", f, err.Error()) + return s + } + out.Close() + stt, err := os.Stat("imageoptimizer.tmp") + if err != nil { + fmt.Printf("Error reading re-compressed temp file: %s\n", err.Error()) + return s + } + s.StartingBytes += uint64(st.Size()) + decrease:=st.Size() - stt.Size() + diff := ((float64(decrease) / float64(st.Size())) * 100) + if uint(diff) > c.Diff { + // Would replace + if c.Replace { + err = os.Rename(f, "imageoptimizer.replacetmp") + if err != nil { + fmt.Printf("Fatal error moving original file %s: %s\n", f, err.Error()) + os.Exit(1) + } + err = os.Rename("imageoptimizer.tmp", f) + if err != nil { + fmt.Printf("Fatal error replacing original file %s: %s\nOriginal file has been renamed to imageoptimizer.replacetmp", f, err.Error()) + os.Exit(1) + } + os.Remove("imageoptimizer.replacetmp") + } + + + s.EndingBytes += uint64(stt.Size()) + s.FilesReplaced++ + if (!c.Quiet) { + fmt.Printf("Replace %s: From %s (%d bytes) to %s (%d bytes) (%.0f%%)\n", + f, SIFormat(uint64(st.Size())), st.Size(), SIFormat(uint64(stt.Size())), stt.Size(), diff) + } + } else { + if (!c.Quiet) { + fmt.Printf("Not Replacing %s: From %s (%d bytes) to %s (%d bytes) (%.0f%%)\n", + f, SIFormat(uint64(st.Size())), st.Size(), SIFormat(uint64(stt.Size())), stt.Size(), diff) + } + s.EndingBytes += uint64(st.Size()) + } + + s.FilesProcessed++ + return s +} + +// SIFormat prints sizes in a nice human readable way +func SIFormat(num_in uint64) string { + suffix := "B" //just assume bytes + num := float64(num_in) + units := []string{"", "K", "M", "G", "T", "P", "E", "Z"} + for _, unit := range units { + if num < 1000.0 { + return fmt.Sprintf("%3.1f%s%s", num, unit, suffix) + } + num = (num / 1000) + } + return fmt.Sprintf("%.1f%s%s", num, "Yi", suffix) +}