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()
+}