From f7e86353e2caacd34a81ca2b7a967bf12fb04533 Mon Sep 17 00:00:00 2001 From: gigirassy Date: Fri, 13 Mar 2026 02:51:29 +0100 Subject: [PATCH] Add main.go --- main.go | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 main.go diff --git a/main.go b/main.go new file mode 100644 index 0000000..8e13e90 --- /dev/null +++ b/main.go @@ -0,0 +1,214 @@ +package main + +import ( + "flag" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "log" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + + "golang.org/x/image/webp" +) + +func main() { + quality := flag.Int("q", 95, "") + outFmt := flag.String("out", "", "") + workers := flag.Int("workers", runtime.NumCPU(), "") + overwrite := flag.Bool("overwrite", false, "") + flag.Usage = func() {} + flag.Parse() + + files := flag.Args() + if len(files) == 0 { + os.Exit(1) + } + + var forcedFmt string + if *outFmt != "" { + f := strings.ToLower(*outFmt) + if f == "jpeg" { + f = "jpg" + } + if f != "jpg" && f != "png" { + log.Fatalf("unsupported --out format: %s", *outFmt) + } + forcedFmt = f + } + + jobs := make(chan string) + var wg sync.WaitGroup + + for i := 0; i < *workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for path := range jobs { + if err := processFile(path, forcedFmt, *quality, *overwrite); err != nil { + log.Printf("%s: %v\n", path, err) + } + } + }() + } + + for _, f := range files { + jobs <- f + } + close(jobs) + wg.Wait() +} + +func processFile(path, forcedFmt string, quality int, overwrite bool) error { + inF, err := os.Open(path) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer inF.Close() + + ext := strings.ToLower(filepath.Ext(path)) + ext = strings.TrimPrefix(ext, ".") + var img image.Image + + if ext == "webp" { + img, err = webp.Decode(inF) + if err != nil { + return fmt.Errorf("decode webp: %w", err) + } + } else { + img, _, err = image.Decode(inF) + if err != nil { + if try, _ := inF.Seek(0, io.SeekStart); try == 0 { + if dec, werr := webp.Decode(inF); werr == nil { + img = dec + } else { + return fmt.Errorf("decode: %w (webp fallback: %v)", err, werr) + } + } else { + return fmt.Errorf("decode: %w", err) + } + } + } + + var target string + if forcedFmt != "" { + target = forcedFmt + } else { + switch ext { + case "jpg", "jpeg": + target = "jpg" + case "png": + target = "png" + case "webp": + target = "jpg" + default: + target = "jpg" + } + } + + dir := filepath.Dir(path) + base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + outName := fmt.Sprintf("%s-opt.%s", base, target) + outPath := filepath.Join(dir, outName) + if overwrite { + outPath = path + } + + tmpPath := outPath + ".tmp" + outF, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("create output: %w", err) + } + + if target == "jpg" { + if hasAlpha(img) { + img = removeAlpha(img) + } + opts := &jpeg.Options{Quality: clampQuality(quality)} + if err := jpeg.Encode(outF, img, opts); err != nil { + outF.Close() + os.Remove(tmpPath) + return fmt.Errorf("encode jpeg: %w", err) + } + } else if target == "png" { + enc := png.Encoder{CompressionLevel: png.BestCompression} + if err := enc.Encode(outF, img); err != nil { + outF.Close() + os.Remove(tmpPath) + return fmt.Errorf("encode png: %w", err) + } + } else { + outF.Close() + os.Remove(tmpPath) + return fmt.Errorf("unsupported target format: %s", target) + } + outF.Close() + + if overwrite { + if err := os.Rename(tmpPath, outPath); err != nil { + return fmt.Errorf("rename: %w", err) + } + } else { + final := outPath + i := 1 + for { + if _, err := os.Stat(final); os.IsNotExist(err) { + break + } + final = filepath.Join(dir, fmt.Sprintf("%s-opt-%d.%s", base, i, target)) + i++ + } + if err := os.Rename(tmpPath, final); err != nil { + return fmt.Errorf("rename: %w", err) + } + outPath = final + } + + fmt.Printf("%s -> %s\n", path, outPath) + return nil +} + +func clampQuality(q int) int { + if q < 1 { + return 1 + } + if q > 100 { + return 100 + } + return q +} + +func hasAlpha(img image.Image) bool { + switch img.(type) { + case *image.NRGBA, *image.NRGBA64, *image.RGBA, *image.RGBA64: + return true + default: + b := img.Bounds() + if b.Empty() { + return false + } + r, g, b2, a := img.At(b.Min.X, b.Min.Y).RGBA() + return a != 0xffff || r != g || g != b2 + } +} + +func removeAlpha(src image.Image) image.Image { + b := src.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + for y := 0; y < b.Dy(); y++ { + for x := 0; x < b.Dx(); x++ { + dst.Set(x, y, image.White) + } + } + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + dst.Set(x-b.Min.X, y-b.Min.Y, src.At(x, y)) + } + } + return dst +} \ No newline at end of file