diff --git a/.gitignore b/.gitignore index 70102cd..334fc90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin example.* -test.* \ No newline at end of file +test.* +*.exe \ No newline at end of file diff --git a/build.cmd b/build.cmd deleted file mode 100644 index a981f78..0000000 --- a/build.cmd +++ /dev/null @@ -1,6 +0,0 @@ -@echo off - -echo Building... -go build -o %USERPROFILE%/.bin/ffwebp.exe - -echo Done diff --git a/cmd/ffwebp/banner.go b/cmd/ffwebp/banner.go new file mode 100644 index 0000000..460a9ac --- /dev/null +++ b/cmd/ffwebp/banner.go @@ -0,0 +1,36 @@ +package main + +import ( + "runtime" + "sort" + "strings" + + "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/logx" +) + +func banner() { + codecs := codec.All() + + names := make([]string, len(codecs)) + + for i, c := range codecs { + names[i] = c.String() + } + + sort.Strings(names) + + build := strings.Join(names, ",") + + logx.Printf("ffwebp version %s\n", Version) + logx.Printf( + " built with %s %s %s\n", + runtime.Compiler, + runtime.Version(), + runtime.GOARCH, + ) + logx.Printf( + " configuration: -tags %s\n", + build, + ) +} diff --git a/cmd/ffwebp/codecs.go b/cmd/ffwebp/codecs.go deleted file mode 100644 index 351e254..0000000 --- a/cmd/ffwebp/codecs.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import ( - _ "github.com/coalaura/ffwebp/internal/codec/gif" - _ "github.com/coalaura/ffwebp/internal/codec/jpeg" - _ "github.com/coalaura/ffwebp/internal/codec/png" -) diff --git a/cmd/ffwebp/main.go b/cmd/ffwebp/main.go index 87d0c31..1f52294 100644 --- a/cmd/ffwebp/main.go +++ b/cmd/ffwebp/main.go @@ -3,10 +3,13 @@ package main import ( "context" "io" - "log" "os" + "path/filepath" + "time" + _ "github.com/coalaura/ffwebp/internal/builtins" "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/logx" "github.com/coalaura/ffwebp/internal/opts" "github.com/urfave/cli/v3" ) @@ -14,8 +17,6 @@ import ( var Version = "dev" func main() { - log.SetOutput(os.Stderr) - flags := codec.Flags([]cli.Flag{ &cli.StringFlag{ Name: "input", @@ -45,10 +46,17 @@ func main() { Aliases: []string{"l"}, Usage: "force lossless mode (overrides --quality)", }, - &cli.StringFlag{ - Name: "resize", - Aliases: []string{"r"}, - Usage: "WxH, Wx or xH (keep aspect)", + &cli.BoolFlag{ + Name: "silent", + Aliases: []string{"s"}, + Usage: "hides all output", + Action: func(_ context.Context, _ *cli.Command, silent bool) error { + if silent { + logx.SetSilent() + } + + return nil + }, }, }) @@ -66,22 +74,27 @@ func main() { } if err := app.Run(context.Background(), os.Args); err != nil { - log.Fatal(err) + logx.Errorf("fatal: %v", err) + os.Exit(1) } } func run(_ context.Context, cmd *cli.Command) error { + banner() + var ( input string output string common opts.Common - reader io.Reader = os.Stdin - writer io.Writer = os.Stdout + reader io.Reader = os.Stdin + writer *countWriter = &countWriter{w: os.Stdout} ) if input = cmd.String("input"); input != "-" { + logx.Printf("opening input file %q\n", filepath.ToSlash(input)) + file, err := os.OpenFile(input, os.O_RDONLY, 0) if err != nil { return err @@ -90,9 +103,13 @@ func run(_ context.Context, cmd *cli.Command) error { defer file.Close() reader = file + } else { + logx.Printf("reading input from \n") } if output = cmd.String("output"); output != "-" { + logx.Printf("opening output file %q\n", filepath.ToSlash(output)) + file, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { return err @@ -100,31 +117,47 @@ func run(_ context.Context, cmd *cli.Command) error { defer file.Close() - writer = file + writer = &countWriter{w: file} + } else { + logx.Printf("writing output to \n") } common.Quality = cmd.Int("quality") common.Lossless = cmd.Bool("lossless") + common.FillDefaults() + + sniffed, reader, err := codec.Sniff(reader) + if err != nil { + return err + } + + logx.Printf("sniffed codec: %s (%q)\n", sniffed.Codec, sniffed) + oCodec, err := codec.Detect(output, cmd.String("codec")) if err != nil { return err } - iCodec, reader, err := codec.Sniff(reader) + logx.Printf("output codec: %s (forced=%v)\n", oCodec, cmd.IsSet("codec")) + + t0 := time.Now() + + img, err := sniffed.Codec.Decode(reader) if err != nil { return err } - img, err := iCodec.Decode(reader) + logx.Printf("decoded image: %dx%d %s in %s\n", img.Bounds().Dx(), img.Bounds().Dy(), colorModel(img), time.Since(t0).Truncate(time.Millisecond)) + + t1 := time.Now() + + err = oCodec.Encode(writer, img, common) if err != nil { return err } - resized, err := resize(img, cmd) - if err != nil { - return err - } + logx.Printf("encoded %d KiB in %s\n", (writer.n+1023)/1024, time.Since(t1).Truncate(time.Millisecond)) - return oCodec.Encode(writer, resized, common) + return nil } diff --git a/cmd/ffwebp/resize.go b/cmd/ffwebp/resize.go deleted file mode 100644 index 9de1d6b..0000000 --- a/cmd/ffwebp/resize.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "errors" - "image" - "strconv" - "strings" - - "github.com/disintegration/imaging" - "github.com/urfave/cli/v3" -) - -func resize(img image.Image, cmd *cli.Command) (image.Image, error) { - options := strings.ToLower(cmd.String("resize")) - - index := strings.Index(options, "x") - if index == -1 { - return img, nil - } - - var ( - width int - height int - ) - - wRaw := options[:index] - if wRaw != "" { - w64, err := strconv.ParseInt(wRaw, 10, 64) - if err != nil { - return nil, err - } - - width = int(max(0, w64)) - } - - hRaw := options[index:] - if hRaw != "" { - h64, err := strconv.ParseInt(hRaw, 10, 64) - if err != nil { - return nil, err - } - - height = int(max(0, h64)) - } - - if width == 0 && height == 0 { - return nil, errors.New("at least one size needs to be specified for resizing") - } - - resized := imaging.Resize(img, width, height, imaging.Lanczos) - - return resized, nil -} diff --git a/cmd/ffwebp/utils.go b/cmd/ffwebp/utils.go new file mode 100644 index 0000000..d442c33 --- /dev/null +++ b/cmd/ffwebp/utils.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "image" + "image/color" +) + +func colorModel(img image.Image) string { + model := img.ColorModel() + + switch model { + case color.RGBAModel: + return "RGBA" + case color.RGBA64Model: + return "RGBA64" + case color.NRGBAModel: + return "NRGBA" + case color.NRGBA64Model: + return "NRGBA64" + case color.AlphaModel: + return "Alpha" + case color.Alpha16Model: + return "Alpha16" + case color.GrayModel: + return "Gray" + case color.Gray16Model: + return "Gray16" + } + + return fmt.Sprintf("%T", model) +} diff --git a/cmd/ffwebp/writer.go b/cmd/ffwebp/writer.go new file mode 100644 index 0000000..cbaad51 --- /dev/null +++ b/cmd/ffwebp/writer.go @@ -0,0 +1,16 @@ +package main + +import "io" + +type countWriter struct { + w io.Writer + n int64 +} + +func (cw *countWriter) Write(p []byte) (int, error) { + m, err := cw.w.Write(p) + + cw.n += int64(m) + + return m, err +} diff --git a/go.mod b/go.mod index 271f398..818f443 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,6 @@ module github.com/coalaura/ffwebp go 1.24.2 -require ( - github.com/disintegration/imaging v1.6.2 - github.com/urfave/cli/v3 v3.3.8 -) +require github.com/urfave/cli/v3 v3.3.8 require golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 diff --git a/go.sum b/go.sum index e4f7dac..eaef168 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= -github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= diff --git a/cmd/ffwebp/codecs_bmp.go b/internal/builtins/bmp.go similarity index 52% rename from cmd/ffwebp/codecs_bmp.go rename to internal/builtins/bmp.go index e353b24..0ac17db 100644 --- a/cmd/ffwebp/codecs_bmp.go +++ b/internal/builtins/bmp.go @@ -1,7 +1,7 @@ -//go:build bmp -// +build bmp +//go:build bmp || full +// +build bmp full -package main +package builtins import ( _ "github.com/coalaura/ffwebp/internal/codec/bmp" diff --git a/internal/builtins/builtins.go b/internal/builtins/builtins.go new file mode 100644 index 0000000..7b4beb8 --- /dev/null +++ b/internal/builtins/builtins.go @@ -0,0 +1,3 @@ +package builtins + +// does nothing :) diff --git a/internal/builtins/gif.go b/internal/builtins/gif.go new file mode 100644 index 0000000..31a798b --- /dev/null +++ b/internal/builtins/gif.go @@ -0,0 +1,8 @@ +//go:build gif || core || full +// +build gif core full + +package builtins + +import ( + _ "github.com/coalaura/ffwebp/internal/codec/gif" +) diff --git a/internal/builtins/jpeg.go b/internal/builtins/jpeg.go new file mode 100644 index 0000000..90c9d74 --- /dev/null +++ b/internal/builtins/jpeg.go @@ -0,0 +1,8 @@ +//go:build jpeg || core || full +// +build jpeg core full + +package builtins + +import ( + _ "github.com/coalaura/ffwebp/internal/codec/jpeg" +) diff --git a/internal/builtins/png.go b/internal/builtins/png.go new file mode 100644 index 0000000..8f2ee61 --- /dev/null +++ b/internal/builtins/png.go @@ -0,0 +1,8 @@ +//go:build png || core || full +// +build png core full + +package builtins + +import ( + _ "github.com/coalaura/ffwebp/internal/codec/png" +) diff --git a/cmd/ffwebp/codecs_tiff.go b/internal/builtins/tiff.go similarity index 51% rename from cmd/ffwebp/codecs_tiff.go rename to internal/builtins/tiff.go index 12809aa..18b277c 100644 --- a/cmd/ffwebp/codecs_tiff.go +++ b/internal/builtins/tiff.go @@ -1,7 +1,7 @@ -//go:build tiff -// +build tiff +//go:build tiff || full +// +build tiff full -package main +package builtins import ( _ "github.com/coalaura/ffwebp/internal/codec/tiff" diff --git a/internal/codec/bmp/bmp.go b/internal/codec/bmp/bmp.go index 5289ae8..8a731c7 100644 --- a/internal/codec/bmp/bmp.go +++ b/internal/codec/bmp/bmp.go @@ -18,7 +18,7 @@ func init() { type impl struct{} -func (impl) Name() string { +func (impl) String() string { return "bmp" } @@ -30,20 +30,20 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { return flags } -func (impl) Sniff(reader io.ReaderAt) (int, error) { +func (impl) Sniff(reader io.ReaderAt) (int, []byte, error) { magic := []byte{0x42, 0x4D} buf := make([]byte, 2) if _, err := reader.ReadAt(buf, 0); err != nil { - return 0, err + return 0, nil, err } if bytes.Equal(buf, magic) { - return 100, nil + return 100, magic, nil } - return 0, nil + return 0, nil, nil } func (impl) Decode(reader io.Reader) (image.Image, error) { diff --git a/internal/codec/codec.go b/internal/codec/codec.go index d3e3ffd..1583c40 100644 --- a/internal/codec/codec.go +++ b/internal/codec/codec.go @@ -9,12 +9,12 @@ import ( ) type Codec interface { - Name() string + String() string Flags([]cli.Flag) []cli.Flag Extensions() []string - Sniff(io.ReaderAt) (int, error) + Sniff(io.ReaderAt) (int, []byte, error) Decode(io.Reader) (image.Image, error) Encode(io.Writer, image.Image, opts.Common) error } @@ -22,7 +22,7 @@ type Codec interface { var codecs = map[string]Codec{} func Register(c Codec) { - codecs[c.Name()] = c + codecs[c.String()] = c } func Flags(flags []cli.Flag) []cli.Flag { diff --git a/internal/codec/detect.go b/internal/codec/detect.go index 11598d1..577323f 100644 --- a/internal/codec/detect.go +++ b/internal/codec/detect.go @@ -9,7 +9,27 @@ import ( "strings" ) -func Sniff(reader io.Reader) (Codec, io.Reader, error) { +type Sniffed struct { + Header []byte + Confidence int + Codec Codec +} + +func (s *Sniffed) String() string { + var builder strings.Builder + + for _, b := range s.Header { + if b >= 32 && b <= 126 { + builder.WriteByte(b) + } else { + builder.WriteRune('.') + } + } + + return builder.String() +} + +func Sniff(reader io.Reader) (*Sniffed, io.Reader, error) { buf, err := io.ReadAll(reader) if err != nil { return nil, nil, err @@ -18,18 +38,20 @@ func Sniff(reader io.Reader) (Codec, io.Reader, error) { ra := bytes.NewReader(buf) var ( - guess Codec best int + magic []byte + guess Codec ) for _, codec := range codecs { - confidence, err := codec.Sniff(ra) + confidence, header, err := codec.Sniff(ra) if err != nil { return nil, nil, err } if confidence > best { best = confidence + magic = header guess = codec } } @@ -38,7 +60,11 @@ func Sniff(reader io.Reader) (Codec, io.Reader, error) { return nil, nil, errors.New("unknown format") } - return guess, bytes.NewReader(buf), nil + return &Sniffed{ + Header: magic, + Confidence: best, + Codec: guess, + }, bytes.NewReader(buf), nil } func Detect(output, override string) (Codec, error) { diff --git a/internal/codec/gif/gif.go b/internal/codec/gif/gif.go index be9d52b..ba81d79 100644 --- a/internal/codec/gif/gif.go +++ b/internal/codec/gif/gif.go @@ -8,6 +8,7 @@ import ( "io" "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/logx" "github.com/coalaura/ffwebp/internal/opts" "github.com/urfave/cli/v3" ) @@ -20,7 +21,7 @@ func init() { type impl struct{} -func (impl) Name() string { +func (impl) String() string { return "gif" } @@ -44,21 +45,25 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { }) } -func (impl) Sniff(reader io.ReaderAt) (int, error) { +func (impl) Sniff(reader io.ReaderAt) (int, []byte, error) { magic7a := []byte("GIF87a") magic9a := []byte("GIF89a") buf := make([]byte, 6) if _, err := reader.ReadAt(buf, 0); err != nil { - return 0, err + return 0, nil, err } - if bytes.Equal(buf, magic7a) || bytes.Equal(buf, magic9a) { - return 100, nil + if bytes.Equal(buf, magic7a) { + return 100, magic7a, nil } - return 0, nil + if bytes.Equal(buf, magic9a) { + return 100, magic9a, nil + } + + return 0, nil, nil } func (impl) Decode(reader io.Reader) (image.Image, error) { @@ -66,6 +71,8 @@ func (impl) Decode(reader io.Reader) (image.Image, error) { } func (impl) Encode(writer io.Writer, img image.Image, options opts.Common) error { + logx.Printf("gif: colors=%d\n", numColors) + return gif.Encode(writer, img, &gif.Options{ NumColors: numColors, }) diff --git a/internal/codec/jpeg/jpeg.go b/internal/codec/jpeg/jpeg.go index c34c944..6c325af 100644 --- a/internal/codec/jpeg/jpeg.go +++ b/internal/codec/jpeg/jpeg.go @@ -7,6 +7,7 @@ import ( "io" "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/logx" "github.com/coalaura/ffwebp/internal/opts" "github.com/urfave/cli/v3" ) @@ -17,7 +18,7 @@ func init() { codec.Register(impl{}) } -func (impl) Name() string { +func (impl) String() string { return "jpeg" } @@ -29,20 +30,20 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { return flags } -func (impl) Sniff(reader io.ReaderAt) (int, error) { - marker := []byte{0xFF, 0xD8, 0xFF} +func (impl) Sniff(reader io.ReaderAt) (int, []byte, error) { + magic := []byte{0xFF, 0xD8, 0xFF} buf := make([]byte, 3) if _, err := reader.ReadAt(buf, 0); err != nil { - return 0, err + return 0, nil, err } - if bytes.Equal(buf, marker) { - return 100, nil + if bytes.Equal(buf, magic) { + return 100, magic, nil } - return 0, nil + return 0, nil, nil } func (impl) Decode(reader io.Reader) (image.Image, error) { @@ -50,6 +51,8 @@ func (impl) Decode(reader io.Reader) (image.Image, error) { } func (impl) Encode(writer io.Writer, img image.Image, options opts.Common) error { + logx.Printf("jpeg: quality=%d\n", options.Quality) + return jpeg.Encode(writer, img, &jpeg.Options{ Quality: options.Quality, }) diff --git a/internal/codec/png/png.go b/internal/codec/png/png.go index bbdc0ba..cfc8ff8 100644 --- a/internal/codec/png/png.go +++ b/internal/codec/png/png.go @@ -8,6 +8,7 @@ import ( "io" "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/logx" "github.com/coalaura/ffwebp/internal/opts" "github.com/urfave/cli/v3" ) @@ -22,7 +23,7 @@ func init() { codec.Register(impl{}) } -func (impl) Name() string { +func (impl) String() string { return "png" } @@ -46,20 +47,20 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { }) } -func (impl) Sniff(reader io.ReaderAt) (int, error) { +func (impl) Sniff(reader io.ReaderAt) (int, []byte, error) { magic := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} buf := make([]byte, len(magic)) if _, err := reader.ReadAt(buf, 0); err != nil { - return 0, err + return 0, nil, err } if bytes.Equal(buf, magic) { - return 100, nil + return 100, magic, nil } - return 0, nil + return 0, nil, nil } func (impl) Decode(reader io.Reader) (image.Image, error) { @@ -67,6 +68,8 @@ func (impl) Decode(reader io.Reader) (image.Image, error) { } func (impl) Encode(writer io.Writer, img image.Image, _ opts.Common) error { + logx.Printf("png: compression=%d\n", compression) + encoder := png.Encoder{ CompressionLevel: compressionLevel(compression), } diff --git a/internal/codec/tiff/tiff.go b/internal/codec/tiff/tiff.go index a78a544..2e36dd6 100644 --- a/internal/codec/tiff/tiff.go +++ b/internal/codec/tiff/tiff.go @@ -9,6 +9,7 @@ import ( "golang.org/x/image/tiff" "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/logx" "github.com/coalaura/ffwebp/internal/opts" "github.com/urfave/cli/v3" ) @@ -24,7 +25,7 @@ func init() { type impl struct{} -func (impl) Name() string { +func (impl) String() string { return "tiff" } @@ -56,21 +57,25 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { ) } -func (impl) Sniff(reader io.ReaderAt) (int, error) { +func (impl) Sniff(reader io.ReaderAt) (int, []byte, error) { magicLE := []byte{0x49, 0x49, 0x2A, 0x00} magicBE := []byte{0x4D, 0x4D, 0x00, 0x2A} buf := make([]byte, 4) if _, err := reader.ReadAt(buf, 0); err != nil { - return 0, err + return 0, nil, err } - if bytes.Equal(buf, magicLE) || bytes.Equal(buf, magicBE) { - return 100, nil + if bytes.Equal(buf, magicLE) { + return 100, magicLE, nil } - return 0, nil + if bytes.Equal(buf, magicBE) { + return 100, magicBE, nil + } + + return 0, nil, nil } func (impl) Decode(reader io.Reader) (image.Image, error) { @@ -78,6 +83,8 @@ func (impl) Decode(reader io.Reader) (image.Image, error) { } func (impl) Encode(writer io.Writer, img image.Image, options opts.Common) error { + logx.Printf("tiff: compression=%d predictor=%t\n", compression, predictor) + return tiff.Encode(writer, img, &tiff.Options{ Compression: compressionType(compression), Predictor: predictor, diff --git a/internal/logx/logx.go b/internal/logx/logx.go new file mode 100644 index 0000000..35ee5ff --- /dev/null +++ b/internal/logx/logx.go @@ -0,0 +1,54 @@ +package logx + +import ( + "fmt" + "image" + "os" + "sync/atomic" + "time" +) + +var enabled atomic.Bool + +func init() { + enabled.Store(true) +} + +func SetSilent() { + enabled.Store(false) +} + +func Printf(format string, a ...any) { + if !enabled.Load() { + return + } + + for i, v := range a { + switch r := v.(type) { + case time.Time: + a[i] = time.Since(r) + case image.Image: + b := r.Bounds() + + a[i] = fmt.Sprintf("%dx%dx", b.Dx(), b.Dy()) + default: + a[i] = v + } + } + + fmt.Fprintf(os.Stderr, format, a...) +} + +func PrintKV(codec, key string, val any) { + if !enabled.Load() { + return + } + + Printf("%s: %s=%v\n", codec, key, val) +} + +func Errorf(f string, a ...any) { + fmt.Fprintf(os.Stderr, f, a...) + + os.Exit(1) +} diff --git a/run.cmd b/run.cmd new file mode 100644 index 0000000..361b1da --- /dev/null +++ b/run.cmd @@ -0,0 +1,6 @@ +@echo off + +echo Rebuilding... +go build -tags full -o ffwebp.exe .\cmd\ffwebp + +.\ffwebp.exe %* \ No newline at end of file