Files
2026-03-13 02:51:29 +01:00

214 lines
4.1 KiB
Go

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
}