mirror of
https://github.com/coalaura/ffwebp.git
synced 2025-07-17 22:04:35 +00:00
various improvements
This commit is contained in:
@ -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)
|
||||
}
|
||||
|
1
go.mod
1
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
|
||||
|
6
go.sum
6
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=
|
||||
|
24
help.go
24
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] <input> [output]\n")
|
||||
println("ffwebp [options] <input> [output]\n")
|
||||
|
||||
arguments.ShowHelp(true)
|
||||
|
||||
|
46
image.go
46
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 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 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 func(output io.Writer, img image.Image) error {
|
||||
return encoder.Encode(output, img)
|
||||
}, nil
|
||||
case "gif":
|
||||
options := GetGifOptions()
|
||||
|
||||
LogGifOptions(options)
|
||||
table.AddGifOptions(options)
|
||||
|
||||
return func(output io.Writer, img image.Image) error {
|
||||
return gif.Encode(output, img, options)
|
||||
}, nil
|
||||
case "bmp":
|
||||
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 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 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":
|
||||
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)
|
||||
}
|
||||
|
27
log.go
27
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)
|
||||
}
|
||||
|
34
main.go
34
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.")
|
||||
}
|
||||
|
56
options.go
56
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:
|
||||
|
26
palette.go
Normal file
26
palette.go
Normal file
@ -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
|
||||
}
|
134
table.go
Normal file
134
table.go
Normal file
@ -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()
|
||||
}
|
Reference in New Issue
Block a user