From 04e61b791fd7b4a81bf14cf1c3491c14236344a8 Mon Sep 17 00:00:00 2001 From: Laura Date: Wed, 22 Jan 2025 18:19:20 +0100 Subject: [PATCH] various improvements --- ffwebp_test.go | 5 +- go.mod | 1 + go.sum | 6 +-- help.go | 24 +++++---- image.go | 62 ++++++++++++++++------- log.go | 27 +++++++--- main.go | 34 ++++++------- options.go | 56 +++------------------ palette.go | 26 ++++++++++ table.go | 134 +++++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 266 insertions(+), 109 deletions(-) create mode 100644 palette.go create mode 100644 table.go diff --git a/ffwebp_test.go b/ffwebp_test.go index a09f013..6f2e659 100644 --- a/ffwebp_test.go +++ b/ffwebp_test.go @@ -27,6 +27,9 @@ var ( func TestFFWebP(t *testing.T) { opts.Silent = true + opts.Format = "png" + + encoder, _ := ResolveImageEncoder() for _, file := range TestFiles { log.Printf("Testing file: %s\n", file) @@ -47,7 +50,7 @@ func TestFFWebP(t *testing.T) { var result bytes.Buffer - err = WriteImage(&result, img, "png") + err = encoder(&result, img) if err != nil { log.Fatalf("Failed to encode png image: %v", err) } diff --git a/go.mod b/go.mod index ee20998..826c6af 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.4 require ( github.com/biessek/golang-ico v0.0.0-20180326222316-d348d9ea4670 github.com/coalaura/arguments v1.5.2 + github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 github.com/gen2brain/avif v0.3.2 github.com/gen2brain/heic v0.3.1 github.com/gen2brain/jpegxl v0.3.1 diff --git a/go.sum b/go.sum index 267fd29..ecaec86 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,11 @@ github.com/biessek/golang-ico v0.0.0-20180326222316-d348d9ea4670 h1:FQPKKjDhzG0T4ew6dm6MGrXb4PRAi8ZmTuYuxcF62BM= github.com/biessek/golang-ico v0.0.0-20180326222316-d348d9ea4670/go.mod h1:iRWAFbKXMMkVQyxZ1PfGlkBr1TjATx1zy2MRprV7A3Q= -github.com/coalaura/arguments v1.5.0 h1:apdZXxINepy8qCHYKJYEnLHvV6VbYgsIMPbZCWZAPQY= -github.com/coalaura/arguments v1.5.0/go.mod h1:F5cdI+Gn1qi5K6qqvAdxdTD2TXkny+gTKU0o6NN1MlU= -github.com/coalaura/arguments v1.5.1 h1:Gc7uODI3WlcVxmQpxoUQF7Y2j3EMDpSlVqhmGMtLC8Q= -github.com/coalaura/arguments v1.5.1/go.mod h1:F5cdI+Gn1qi5K6qqvAdxdTD2TXkny+gTKU0o6NN1MlU= github.com/coalaura/arguments v1.5.2 h1:hRLKo6XmAzCDOS/unCUVAIYl3WU/i6QX59nBh0T31cw= github.com/coalaura/arguments v1.5.2/go.mod h1:F5cdI+Gn1qi5K6qqvAdxdTD2TXkny+gTKU0o6NN1MlU= github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA= github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= +github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 h1:BBade+JlV/f7JstZ4pitd4tHhpN+w+6I+LyOS7B4fyU= +github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4/go.mod h1:H7chHJglrhPPzetLdzBleF8d22WYOv7UM/lEKYiwlKM= github.com/gen2brain/avif v0.3.2 h1:XUR0CBl5n4ISFJE8/pc1RMEKt5KUVoW8InctN+M7+DQ= github.com/gen2brain/avif v0.3.2/go.mod h1:tdL2sV6oOJXBZZvT5iP55VEM1X2c3/yJmYKMJTl8fXg= github.com/gen2brain/heic v0.3.1 h1:ClY5YTdXdIanw7pe9ZVUM9XcsqH6CCCa5CZBlm58qOs= diff --git a/help.go b/help.go index 2a3ee7f..bf9e22e 100644 --- a/help.go +++ b/help.go @@ -1,27 +1,29 @@ package main import ( - "fmt" "os" "github.com/coalaura/arguments" ) +func header() { + println(" __ __ _") + println(" / _|/ _|_ _____| |__ _ __") + println("| |_| |_\\ \\ /\\ / / _ \\ '_ \\| '_ \\") + println("| _| _|\\ V V / __/ |_) | |_) |") + println("|_| |_| \\_/\\_/ \\___|_.__/| .__/") + print(" |_| ") + println(Version) + + println() +} + func help() { if !opts.Help { return } - println(" __ __ _") - println(" / _|/ _| | |") - println("| |_| |___ _____| |__ _ __") - println("| _| _\\ \\ /\\ / / _ \\ '_ \\| '_ \\") - println("| | | | \\ V V / __/ |_) | |_) |") - println("|_| |_| \\_/\\_/ \\___|_.__/| .__/") - println(" | |") - fmt.Printf(" %s |_|\n", Version) - - println("\nffwebp [options] [output]\n") + println("ffwebp [options] [output]\n") arguments.ShowHelp(true) diff --git a/image.go b/image.go index b4a26c0..ae44f31 100644 --- a/image.go +++ b/image.go @@ -15,6 +15,8 @@ import ( "golang.org/x/image/tiff" ) +type Encoder func(io.Writer, image.Image) error + func ReadImage(input io.ReadSeeker) (image.Image, error) { decoder, err := GetDecoderFromContent(input) if err != nil { @@ -24,55 +26,77 @@ func ReadImage(input io.ReadSeeker) (image.Image, error) { return decoder(input) } -func WriteImage(output io.Writer, img image.Image, format string) error { - switch format { +func ResolveImageEncoder() (Encoder, error) { + table := NewOptionsTable() + + switch opts.Format { case "webp": options := GetWebPOptions() - LogWebPOptions(options) + table.AddWebPOptions(options) - return webp.Encode(output, img, options) + return func(output io.Writer, img image.Image) error { + return webp.Encode(output, img, options) + }, nil case "jpeg": options := GetJpegOptions() - LogJpegOptions(options) + table.AddJpegOptions(options) - return jpeg.Encode(output, img, options) + return func(output io.Writer, img image.Image) error { + return jpeg.Encode(output, img, options) + }, nil case "png": encoder := GetPNGOptions() - LogPNGOptions(encoder) + table.AddPNGOptions(encoder) - return encoder.Encode(output, img) + return func(output io.Writer, img image.Image) error { + return encoder.Encode(output, img) + }, nil case "gif": options := GetGifOptions() - LogGifOptions(options) + table.AddGifOptions(options) - return gif.Encode(output, img, options) + return func(output io.Writer, img image.Image) error { + return gif.Encode(output, img, options) + }, nil case "bmp": - return bmp.Encode(output, img) + return func(output io.Writer, img image.Image) error { + return bmp.Encode(output, img) + }, nil case "tiff": options := GetTiffOptions() - LogTiffOptions(options) + table.AddTiffOptions(options) - return tiff.Encode(output, img, options) + return func(output io.Writer, img image.Image) error { + return tiff.Encode(output, img, options) + }, nil case "avif": options := GetAvifOptions() - LogAvifOptions(options) + table.AddAvifOptions(options) - return avif.Encode(output, img, options) + return func(output io.Writer, img image.Image) error { + return avif.Encode(output, img, options) + }, nil case "jxl": options := GetJxlOptions() - LogJxlOptions(options) + table.AddJxlOptions(options) - jpegxl.Encode(output, img, options) + return func(output io.Writer, img image.Image) error { + return jpegxl.Encode(output, img, options) + }, nil case "ico": - return ico.Encode(output, img) + table.Print() + + return func(output io.Writer, img image.Image) error { + return ico.Encode(output, img) + }, nil } - return fmt.Errorf("unsupported output format: %s", format) + return nil, fmt.Errorf("unsupported output format: %s", opts.Format) } diff --git a/log.go b/log.go index 0cf8d1f..4eeb960 100644 --- a/log.go +++ b/log.go @@ -3,24 +3,39 @@ package main import ( "fmt" "os" + + "github.com/coalaura/arguments" ) var ( silent bool ) -func info(fm string, args ...interface{}) { +func debug(msg string) { if silent { return } - fmt.Printf(fm, args...) - fmt.Println() + b := arguments.NewBuilder(true) + + b.Mute() + b.WriteString("# ") + b.WriteString(msg) + + println(b.String()) } -func fatalf(fm string, args ...interface{}) { - fmt.Printf("ERROR: "+fm, args...) - fmt.Println() +func fatalf(format string, args ...interface{}) { + must(fmt.Errorf(format, args...)) +} + +func must(err error) { + if err == nil { + return + } + + print("\033[38;5;160mERROR: \033[38;5;248m") + println(err.Error()) os.Exit(1) } diff --git a/main.go b/main.go index 650fca1..c6c51f4 100644 --- a/main.go +++ b/main.go @@ -5,14 +5,16 @@ import ( ) func main() { + header() parse() - info("Reading input image...") + encoder, err := ResolveImageEncoder() + must(err) + + debug("Reading input image...") in, err := os.OpenFile(opts.Input, os.O_RDONLY, 0) - if err != nil { - fatalf("Failed to open input file: %s", err) - } + must(err) defer in.Close() @@ -24,27 +26,23 @@ func main() { out = os.Stdout } else { out, err = os.OpenFile(opts.Output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) - if err != nil { - fatalf("Failed to open output file: %s", err) - } + must(err) defer out.Close() } - info("Decoding input image...") + debug("Decoding input image...") img, err := ReadImage(in) - if err != nil { - fatalf("Failed to read image: %s", err) - } - - info("Using output format: %s", opts.Format) + must(err) // Write image - info("Encoding output image...") + debug("Encoding output image...") - err = WriteImage(out, img, opts.Format) - if err != nil { - fatalf("Failed to write image: %s", err) - } + img = Quantize(img) + + err = encoder(out, img) + must(err) + + debug("Completed.") } diff --git a/options.go b/options.go index 22b1a4c..dc9db5c 100644 --- a/options.go +++ b/options.go @@ -38,7 +38,7 @@ var opts = Options{ Output: "", Silent: false, - NumColors: 256, + NumColors: 0, Effort: 10, Format: "", Lossless: false, @@ -59,14 +59,12 @@ func parse() { // Common image options arguments.Register("quality", 'q', &opts.Quality).WithHelp("[avif|jpeg|jxl|webp] Quality level (1-100)") + arguments.Register("colors", 'c', &opts.NumColors).WithHelp("Number of colors to use when quantizing (0=no-quantizing, 256=max-colors)") // AVIF arguments.Register("ratio", 'r', &opts.Ratio).WithHelp("[avif] YCbCr subsample-ratio (0=444, 1=422, 2=420, 3=440, 4=411, 5=410)") arguments.Register("speed", 'p', &opts.Speed).WithHelp("[avif] Encoder speed level (0=fast, 10=slower-better)") - // GIF - arguments.Register("colors", 'c', &opts.NumColors).WithHelp("[gif] Number of colors to use (1-256)") - // JXL arguments.Register("effort", 'e', &opts.Effort).WithHelp("[jxl] Encoder effort level (0=fast, 10=slower-better)") @@ -81,7 +79,7 @@ func parse() { arguments.Register("lossless", 'l', &opts.Lossless).WithHelp("[webp] Use lossless compression") arguments.Register("method", 'm', &opts.Method).WithHelp("[webp] Encoder method (0=fast, 6=slower-better)") - arguments.Parse() + must(arguments.Parse()) help() @@ -116,9 +114,9 @@ func parse() { opts.Format = "jpeg" } - // NumColors must be between 1 and 256 - if opts.NumColors < 1 || opts.NumColors > 256 { - opts.NumColors = 256 + // NumColors must be between 0 and 256 + if opts.NumColors < 0 || opts.NumColors > 256 { + opts.NumColors = 0 } // Effort must be between 0 and 10 @@ -161,58 +159,30 @@ func GetWebPOptions() webp.Options { } } -func LogWebPOptions(options webp.Options) { - info("Using output options:") - info(" - lossless: %v", options.Lossless) - info(" - quality: %v", options.Quality) - info(" - method: %v", options.Method) - info(" - exact: %v", options.Exact) -} - func GetJpegOptions() *jpeg.Options { return &jpeg.Options{ Quality: opts.Quality, } } -func LogJpegOptions(options *jpeg.Options) { - info("Using output options:") - info(" - quality: %v", options.Quality) -} - func GetPNGOptions() *png.Encoder { return &png.Encoder{ CompressionLevel: GetPNGCompressionLevel(), } } -func LogPNGOptions(encoder *png.Encoder) { - info("Using output options:") - info(" - level: %s", PNGCompressionLevelToString(encoder.CompressionLevel)) -} - func GetGifOptions() *gif.Options { return &gif.Options{ NumColors: opts.NumColors, } } -func LogGifOptions(options *gif.Options) { - info("Using output options:") - info(" - colors: %v", options.NumColors) -} - func GetTiffOptions() *tiff.Options { return &tiff.Options{ Compression: GetTiffCompressionType(), } } -func LogTiffOptions(options *tiff.Options) { - info("Using output options:") - info(" - compression: %s", TiffCompressionTypeToString(options.Compression)) -} - func GetAvifOptions() avif.Options { return avif.Options{ Quality: opts.Quality, @@ -222,14 +192,6 @@ func GetAvifOptions() avif.Options { } } -func LogAvifOptions(options avif.Options) { - info("Using output options:") - info(" - quality: %v", options.Quality) - info(" - quality-alpha: %v", options.QualityAlpha) - info(" - speed: %v", options.Speed) - info(" - chroma subsampling: %s", options.ChromaSubsampling.String()) -} - func GetJxlOptions() jpegxl.Options { return jpegxl.Options{ Quality: opts.Quality, @@ -237,12 +199,6 @@ func GetJxlOptions() jpegxl.Options { } } -func LogJxlOptions(options jpegxl.Options) { - info("Using output options:") - info(" - quality: %v", options.Quality) - info(" - effort: %v", options.Effort) -} - func GetTiffCompressionType() tiff.CompressionType { switch opts.Compression { case 0: diff --git a/palette.go b/palette.go new file mode 100644 index 0000000..ec34a5c --- /dev/null +++ b/palette.go @@ -0,0 +1,26 @@ +package main + +import ( + "image" + "image/color" + "image/draw" + + "github.com/ericpauley/go-quantize/quantize" +) + +func Quantize(img image.Image) image.Image { + if opts.NumColors == 0 { + return img + } + + bounds := img.Bounds() + + q := quantize.MedianCutQuantizer{} + p := q.Quantize(make([]color.Color, 0, opts.NumColors), img) + + paletted := image.NewPaletted(bounds, p) + + draw.Draw(paletted, bounds, img, image.Point{}, draw.Src) + + return paletted +} diff --git a/table.go b/table.go new file mode 100644 index 0000000..344edf5 --- /dev/null +++ b/table.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "image/gif" + "image/jpeg" + "image/png" + "strings" + + "github.com/coalaura/arguments" + "github.com/gen2brain/avif" + "github.com/gen2brain/jpegxl" + "github.com/gen2brain/webp" + "golang.org/x/image/tiff" +) + +type Option struct { + Format string + Name string + Value interface{} +} + +type OptionsTable struct { + max int + entries []Option + seen map[string]bool +} + +func NewOptionsTable() *OptionsTable { + table := &OptionsTable{ + entries: make([]Option, 0), + seen: make(map[string]bool), + } + + table.Add("format", "%s", opts.Format) + + if opts.NumColors != 0 { + table.Add("colors", "%d", opts.NumColors) + } + + return table +} + +func (t *OptionsTable) Add(name, format string, value interface{}) { + if t.seen[name] { + return + } + + t.seen[name] = true + + t.entries = append(t.entries, Option{ + Format: format, + Name: name, + Value: value, + }) + + if len(name) > t.max { + t.max = len(name) + } +} + +func (t *OptionsTable) Print() { + if opts.Silent { + return + } + + b := arguments.NewBuilder(true) + + b.Name() + b.WriteString("Options:") + + for _, opt := range t.entries { + b.WriteRune('\n') + b.Mute() + b.WriteString(" - ") + b.Name() + b.WriteString(opt.Name) + b.WriteString(strings.Repeat(" ", t.max-len(opt.Name))) + b.Mute() + b.WriteString(": ") + b.Value() + b.WriteString(fmt.Sprintf(opt.Format, opt.Value)) + } + + println(b.String()) +} + +func (t *OptionsTable) AddWebPOptions(options webp.Options) { + t.Add("lossless", "%v", options.Lossless) + t.Add("quality", "%v", options.Quality) + t.Add("method", "%v", options.Method) + t.Add("exact", "%v", options.Exact) + + t.Print() +} + +func (t *OptionsTable) AddJpegOptions(options *jpeg.Options) { + t.Add("quality", "%v", options.Quality) + + t.Print() +} + +func (t *OptionsTable) AddPNGOptions(encoder *png.Encoder) { + t.Add("level", "%s", PNGCompressionLevelToString(encoder.CompressionLevel)) + + t.Print() +} + +func (t *OptionsTable) AddGifOptions(options *gif.Options) { + t.Add("colors", "%v", options.NumColors) + + t.Print() +} + +func (t *OptionsTable) AddTiffOptions(options *tiff.Options) { + t.Add("compression", "%s", TiffCompressionTypeToString(options.Compression)) + + t.Print() +} + +func (t *OptionsTable) AddAvifOptions(options avif.Options) { + t.Add("quality", "%v", options.Quality) + t.Add("speed", "%v", options.Speed) + t.Add("ratios", "%s", options.ChromaSubsampling.String()) + + t.Print() +} + +func (t *OptionsTable) AddJxlOptions(options jpegxl.Options) { + t.Add("quality", "%v", options.Quality) + t.Add("effort", "%v", options.Effort) + + t.Print() +}