diff --git a/cmd/ffwebp/banner.go b/cmd/ffwebp/banner.go index 460a9ac..d70fbfd 100644 --- a/cmd/ffwebp/banner.go +++ b/cmd/ffwebp/banner.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/effects" "github.com/coalaura/ffwebp/internal/logx" ) @@ -20,6 +21,10 @@ func banner() { sort.Strings(names) + if effects.HasEffects() { + names = append(names, "effects") + } + build := strings.Join(names, ",") logx.Printf("ffwebp version %s\n", Version) diff --git a/cmd/ffwebp/main.go b/cmd/ffwebp/main.go index 11775c6..0b48a17 100644 --- a/cmd/ffwebp/main.go +++ b/cmd/ffwebp/main.go @@ -9,6 +9,7 @@ import ( _ "github.com/coalaura/ffwebp/internal/builtins" "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/effects" "github.com/coalaura/ffwebp/internal/logx" "github.com/coalaura/ffwebp/internal/opts" "github.com/nfnt/resize" @@ -18,7 +19,7 @@ import ( var Version = "dev" func main() { - flags := codec.Flags([]cli.Flag{ + flags := []cli.Flag{ &cli.StringFlag{ Name: "input", Aliases: []string{"i"}, @@ -64,7 +65,10 @@ func main() { return nil }, }, - }) + } + + flags = codec.Flags(flags) + flags = effects.Flags(flags) app := &cli.Command{ Name: "ffwebp", @@ -88,6 +92,7 @@ func run(_ context.Context, cmd *cli.Command) error { banner() var ( + n int input string output string @@ -167,12 +172,21 @@ func run(_ context.Context, cmd *cli.Command) error { t1 := time.Now() + img, n, err = effects.ApplyAll(img) + if err != nil { + return err + } else if n > 0 { + logx.Printf("applied %d effect(s) in %s\n", n, time.Since(t1).Truncate(time.Millisecond)) + } + + t2 := time.Now() + err = oCodec.Encode(writer, img, common) if err != nil { return err } - logx.Printf("encoded %d KiB in %s\n", (writer.n+1023)/1024, time.Since(t1).Truncate(time.Millisecond)) + logx.Printf("encoded %d KiB in %s\n", (writer.n+1023)/1024, time.Since(t2).Truncate(time.Millisecond)) return nil } diff --git a/go.mod b/go.mod index 41b3d85..54e6d39 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.5 require github.com/urfave/cli/v3 v3.3.8 require ( + github.com/anthonynsimon/bild v0.14.0 github.com/ftrvxmtrx/tga v0.0.0-20150524081124-bd8e8d5be13a github.com/gen2brain/avif v0.4.4 github.com/gen2brain/jpegxl v0.4.5 @@ -16,7 +17,7 @@ require ( github.com/sergeymakinen/go-ico v1.0.0-beta.0 github.com/spakin/netpbm v1.3.2 github.com/xyproto/xbm v1.0.0 - golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 + golang.org/x/image v0.18.0 ) require ( diff --git a/go.sum b/go.sum index 91b9ff0..d55a92c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync= +github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs= 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/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc= @@ -37,9 +39,8 @@ github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E= github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= github.com/xyproto/xbm v1.0.0 h1:R5/A2+yhyy4V2c626bdIVfS1UNwE3efhiEBuKXU6u3A= github.com/xyproto/xbm v1.0.0/go.mod h1:m2xrjsNmxuzBrx6gs4rSgxgpJWXS01j9KxYgjxhcnoc= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/builtins/effects.go b/internal/builtins/effects.go new file mode 100644 index 0000000..f859dd8 --- /dev/null +++ b/internal/builtins/effects.go @@ -0,0 +1,16 @@ +//go:build effects || core || full +// +build effects core full + +package builtins + +import ( + _ "github.com/coalaura/ffwebp/internal/effects/blur" + _ "github.com/coalaura/ffwebp/internal/effects/brightness" + _ "github.com/coalaura/ffwebp/internal/effects/contrast" + _ "github.com/coalaura/ffwebp/internal/effects/grayscale" + _ "github.com/coalaura/ffwebp/internal/effects/hue" + _ "github.com/coalaura/ffwebp/internal/effects/invert" + _ "github.com/coalaura/ffwebp/internal/effects/saturation" + _ "github.com/coalaura/ffwebp/internal/effects/sepia" + _ "github.com/coalaura/ffwebp/internal/effects/sharpen" +) diff --git a/internal/codec/avif/avif.go b/internal/codec/avif/avif.go index 8c03c6d..f8ceae9 100644 --- a/internal/codec/avif/avif.go +++ b/internal/codec/avif/avif.go @@ -42,7 +42,7 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { return append(flags, &cli.IntFlag{ Name: "avif.quality-alpha", - Usage: "AVIF: alpha channel quality in range [0-100]", + Usage: "AVIF: alpha channel quality (0-100)", Value: 60, Destination: &qualityA, Validator: func(v int) error { @@ -55,7 +55,7 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { }, &cli.IntFlag{ Name: "avif.speed", - Usage: "AVIF: encoding speed in range [0-10] (0=slowest/best)", + Usage: "AVIF: encoding speed (0=slowest/best, 10=fastest/worst)", Value: 6, Destination: &speed, Validator: func(v int) error { diff --git a/internal/codec/gif/gif.go b/internal/codec/gif/gif.go index cfed6ef..8f81c2d 100644 --- a/internal/codec/gif/gif.go +++ b/internal/codec/gif/gif.go @@ -41,7 +41,7 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { Destination: &numColors, Validator: func(value int) error { if value < 1 || value > 256 { - return fmt.Errorf("invalid number of colors: %d", value) + return fmt.Errorf("invalid gif.colors: %d", value) } return nil diff --git a/internal/codec/png/png.go b/internal/codec/png/png.go index 24728bd..8456db5 100644 --- a/internal/codec/png/png.go +++ b/internal/codec/png/png.go @@ -43,7 +43,7 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { Destination: &compression, Validator: func(value int) error { if value < 0 || value > 3 { - return fmt.Errorf("invalid compression level: %d", value) + return fmt.Errorf("invalid png.compression: %d", value) } return nil diff --git a/internal/codec/tiff/tiff.go b/internal/codec/tiff/tiff.go index 1908263..c12e034 100644 --- a/internal/codec/tiff/tiff.go +++ b/internal/codec/tiff/tiff.go @@ -46,7 +46,7 @@ func (impl) Flags(flags []cli.Flag) []cli.Flag { Destination: &compression, Validator: func(value int) error { if value < 0 || value > 4 { - return fmt.Errorf("invalid compression: %d", value) + return fmt.Errorf("invalid tiff.compression: %d", value) } return nil diff --git a/internal/effects/blur/blur.go b/internal/effects/blur/blur.go new file mode 100644 index 0000000..1035ae6 --- /dev/null +++ b/internal/effects/blur/blur.go @@ -0,0 +1,38 @@ +package blur + +import ( + "fmt" + "image" + "strconv" + + "github.com/anthonynsimon/bild/blur" + "github.com/coalaura/ffwebp/internal/effects" + "github.com/coalaura/ffwebp/internal/logx" +) + +type impl struct{} + +func init() { + effects.Register(impl{}) +} + +func (impl) String() string { + return "blur" +} + +func (impl) Apply(img image.Image, args string) (image.Image, error) { + var radius float64 = 3 + + if args != "" { + f64, err := strconv.ParseFloat(args, 64) + if err != nil || f64 <= 0 { + return nil, fmt.Errorf("invalid blur radius: %s", args) + } + + radius = f64 + } + + logx.Printf(" applying blur (radius=%.f)\n", radius) + + return blur.Gaussian(img, radius), nil +} diff --git a/internal/effects/brightness/brightness.go b/internal/effects/brightness/brightness.go new file mode 100644 index 0000000..19267d2 --- /dev/null +++ b/internal/effects/brightness/brightness.go @@ -0,0 +1,38 @@ +package brightness + +import ( + "fmt" + "image" + "strconv" + + "github.com/anthonynsimon/bild/adjust" + "github.com/coalaura/ffwebp/internal/effects" + "github.com/coalaura/ffwebp/internal/logx" +) + +type impl struct{} + +func init() { + effects.Register(impl{}) +} + +func (impl) String() string { + return "brightness" +} + +func (impl) Apply(img image.Image, args string) (image.Image, error) { + var change float64 = 0.1 + + if args != "" { + f64, err := strconv.ParseFloat(args, 64) + if err != nil || f64 < -1 || f64 > 1 || f64 == 0 { + return nil, fmt.Errorf("invalid brightness change: %s", args) + } + + change = f64 + } + + logx.Printf(" applying brightness (change=%.f)\n", change) + + return adjust.Brightness(img, change), nil +} diff --git a/internal/effects/contrast/contrast.go b/internal/effects/contrast/contrast.go new file mode 100644 index 0000000..5a0cf5b --- /dev/null +++ b/internal/effects/contrast/contrast.go @@ -0,0 +1,38 @@ +package contrast + +import ( + "fmt" + "image" + "strconv" + + "github.com/anthonynsimon/bild/adjust" + "github.com/coalaura/ffwebp/internal/effects" + "github.com/coalaura/ffwebp/internal/logx" +) + +type impl struct{} + +func init() { + effects.Register(impl{}) +} + +func (impl) String() string { + return "contrast" +} + +func (impl) Apply(img image.Image, args string) (image.Image, error) { + var change float64 = 0.1 + + if args != "" { + f64, err := strconv.ParseFloat(args, 64) + if err != nil || f64 < -1 || f64 > 1 || f64 == 0 { + return nil, fmt.Errorf("invalid contrast change: %s", args) + } + + change = f64 + } + + logx.Printf(" applying contrast (change=%.f)\n", change) + + return adjust.Contrast(img, change), nil +} diff --git a/internal/effects/effects.go b/internal/effects/effects.go new file mode 100644 index 0000000..43afc33 --- /dev/null +++ b/internal/effects/effects.go @@ -0,0 +1,103 @@ +package effects + +import ( + "fmt" + "image" + "strings" + + "github.com/urfave/cli/v3" +) + +type Effect interface { + String() string + Apply(image.Image, string) (image.Image, error) +} + +type EffectConfig struct { + Effect Effect + Arguments string +} + +var ( + apply string + names []string + registry = make(map[string]Effect) +) + +func Register(e Effect) { + name := e.String() + + names = append(names, name) + + registry[name] = e +} + +func HasEffects() bool { + return len(registry) > 0 +} + +func Flags(flags []cli.Flag) []cli.Flag { + if len(registry) == 0 { + return flags + } + + return append(flags, + &cli.StringFlag{ + Name: "effects", + Aliases: []string{"e"}, + Usage: fmt.Sprintf("list of effects to apply (%s)", strings.Join(names, ", ")), + Value: "", + Destination: &apply, + }, + ) +} + +func ApplyAll(img image.Image) (image.Image, int, error) { + if apply == "" { + return img, 0, nil + } + + list, err := Parse() + if err != nil { + return nil, 0, err + } else if len(list) == 0 { + return img, 0, nil + } + + for _, e := range list { + img, err = e.Effect.Apply(img, e.Arguments) + if err != nil { + return nil, 0, err + } + } + + return img, len(list), nil +} + +func Parse() ([]EffectConfig, error) { + var result []EffectConfig + + for entry := range strings.SplitSeq(apply, ",") { + var ( + name = entry + arguments string + ) + + if index := strings.Index(entry, ":"); index != -1 { + name = entry[:index] + arguments = entry[index+1:] + } + + effect, ok := registry[name] + if !ok { + return nil, fmt.Errorf("invalid effect: %s", name) + } + + result = append(result, EffectConfig{ + Effect: effect, + Arguments: arguments, + }) + } + + return result, nil +} diff --git a/internal/effects/grayscale/grayscale.go b/internal/effects/grayscale/grayscale.go new file mode 100644 index 0000000..c7b6e4f --- /dev/null +++ b/internal/effects/grayscale/grayscale.go @@ -0,0 +1,25 @@ +package grayscale + +import ( + "image" + + "github.com/anthonynsimon/bild/effect" + "github.com/coalaura/ffwebp/internal/effects" + "github.com/coalaura/ffwebp/internal/logx" +) + +type impl struct{} + +func init() { + effects.Register(impl{}) +} + +func (impl) String() string { + return "grayscale" +} + +func (impl) Apply(img image.Image, _ string) (image.Image, error) { + logx.Printf(" applying grayscale\n") + + return effect.Grayscale(img), nil +} diff --git a/internal/effects/hue/hue.go b/internal/effects/hue/hue.go new file mode 100644 index 0000000..723ac19 --- /dev/null +++ b/internal/effects/hue/hue.go @@ -0,0 +1,38 @@ +package hue + +import ( + "fmt" + "image" + "strconv" + + "github.com/anthonynsimon/bild/adjust" + "github.com/coalaura/ffwebp/internal/effects" + "github.com/coalaura/ffwebp/internal/logx" +) + +type impl struct{} + +func init() { + effects.Register(impl{}) +} + +func (impl) String() string { + return "hue" +} + +func (impl) Apply(img image.Image, args string) (image.Image, error) { + var change int64 = 10 + + if args != "" { + i64, err := strconv.ParseInt(args, 10, 64) + if err != nil || i64 < -360 || i64 > 360 || i64 == 0 { + return nil, fmt.Errorf("invalid hue change: %s", args) + } + + change = i64 + } + + logx.Printf(" applying hue (change=%d)\n", change) + + return adjust.Hue(img, int(change)), nil +} diff --git a/internal/effects/invert/invert.go b/internal/effects/invert/invert.go new file mode 100644 index 0000000..0d3da2b --- /dev/null +++ b/internal/effects/invert/invert.go @@ -0,0 +1,25 @@ +package invert + +import ( + "image" + + "github.com/anthonynsimon/bild/effect" + "github.com/coalaura/ffwebp/internal/effects" + "github.com/coalaura/ffwebp/internal/logx" +) + +type impl struct{} + +func init() { + effects.Register(impl{}) +} + +func (impl) String() string { + return "invert" +} + +func (impl) Apply(img image.Image, _ string) (image.Image, error) { + logx.Printf(" applying invert\n") + + return effect.Invert(img), nil +} diff --git a/internal/effects/saturation/saturation.go b/internal/effects/saturation/saturation.go new file mode 100644 index 0000000..84f002f --- /dev/null +++ b/internal/effects/saturation/saturation.go @@ -0,0 +1,38 @@ +package saturation + +import ( + "fmt" + "image" + "strconv" + + "github.com/anthonynsimon/bild/adjust" + "github.com/coalaura/ffwebp/internal/effects" + "github.com/coalaura/ffwebp/internal/logx" +) + +type impl struct{} + +func init() { + effects.Register(impl{}) +} + +func (impl) String() string { + return "saturation" +} + +func (impl) Apply(img image.Image, args string) (image.Image, error) { + var change float64 = 0.1 + + if args != "" { + f64, err := strconv.ParseFloat(args, 64) + if err != nil || f64 < -1 || f64 > 1 || f64 == 0 { + return nil, fmt.Errorf("invalid saturation change: %s", args) + } + + change = f64 + } + + logx.Printf(" applying saturation (change=%.f)\n", change) + + return adjust.Saturation(img, change), nil +} diff --git a/internal/effects/sepia/grayscale.go b/internal/effects/sepia/grayscale.go new file mode 100644 index 0000000..e3dfb63 --- /dev/null +++ b/internal/effects/sepia/grayscale.go @@ -0,0 +1,25 @@ +package sepia + +import ( + "image" + + "github.com/anthonynsimon/bild/effect" + "github.com/coalaura/ffwebp/internal/effects" + "github.com/coalaura/ffwebp/internal/logx" +) + +type impl struct{} + +func init() { + effects.Register(impl{}) +} + +func (impl) String() string { + return "sepia" +} + +func (impl) Apply(img image.Image, _ string) (image.Image, error) { + logx.Printf(" applying sepia\n") + + return effect.Sepia(img), nil +} diff --git a/internal/effects/sharpen/sharpen.go b/internal/effects/sharpen/sharpen.go new file mode 100644 index 0000000..c7f252b --- /dev/null +++ b/internal/effects/sharpen/sharpen.go @@ -0,0 +1,25 @@ +package sharpen + +import ( + "image" + + "github.com/anthonynsimon/bild/effect" + "github.com/coalaura/ffwebp/internal/effects" + "github.com/coalaura/ffwebp/internal/logx" +) + +type impl struct{} + +func init() { + effects.Register(impl{}) +} + +func (impl) String() string { + return "sharpen" +} + +func (impl) Apply(img image.Image, _ string) (image.Image, error) { + logx.Printf(" applying sharpen\n") + + return effect.Sharpen(img), nil +}