From c09beb0d771c28031569566cb382709167e4a17f Mon Sep 17 00:00:00 2001 From: Laura Date: Thu, 19 Jun 2025 12:25:32 +0200 Subject: [PATCH] rewrite --- .github/workflows/release.yml | 4 +- README.md | 77 +--------- cmd/ffwebp/main.go | 127 ++++++++++++++++ cmd/ffwebp/resize.go | 53 +++++++ ffwebp_test.go | 73 --------- format.go | 176 --------------------- go.mod | 21 +-- go.sum | 39 ++--- help.go | 67 -------- image.go | 102 ------------- internal/codec/codec.go | 50 ++++++ internal/codec/detect.go | 72 +++++++++ internal/codec/jpeg/jpeg.go | 56 +++++++ internal/opts/opts.go | 16 ++ log.go | 41 ----- main.go | 48 ------ options.go | 279 ---------------------------------- palette.go | 26 ---- table.go | 134 ---------------- version.go | 3 - 20 files changed, 396 insertions(+), 1068 deletions(-) create mode 100644 cmd/ffwebp/main.go create mode 100644 cmd/ffwebp/resize.go delete mode 100644 ffwebp_test.go delete mode 100644 format.go delete mode 100644 help.go delete mode 100644 image.go create mode 100644 internal/codec/codec.go create mode 100644 internal/codec/detect.go create mode 100644 internal/codec/jpeg/jpeg.go create mode 100644 internal/opts/opts.go delete mode 100644 log.go delete mode 100644 main.go delete mode 100644 options.go delete mode 100644 palette.go delete mode 100644 table.go delete mode 100644 version.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38cb0a1..19ae5bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,13 +29,11 @@ jobs: - name: Set up Environment run: | mkdir -p build - echo "package main" > version.go - echo "const Version = \"${{ github.ref_name }}\"" >> version.go - name: Build for ${{ matrix.goos }}_${{ matrix.goarch }} run: | if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; else EXT=""; fi - GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -ldflags="-s -w" -trimpath -o build/ffwebp_${{ github.ref_name }}_${{ matrix.goos }}_${{ matrix.goarch }}$EXT + GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -ldflags "-s -w -X 'main.Version=${{ github.ref_name }}'" -trimpath -o build/ffwebp_${{ github.ref_name }}_${{ matrix.goos }}_${{ matrix.goarch }}$EXT - name: Upload build artifact uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index db0fbb2..8c6cb02 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,6 @@ # ffwebp -A fast and flexible command-line image conversion tool supporting multiple formats and advanced compression options. - -## Features - -- Multiple input formats: AVIF, BMP, GIF, HEIC/HEIF, ICO, JPEG, JPEG XL, PNG, TIFF, WebP -- Multiple output formats: AVIF, BMP, GIF, ICO, JPEG, JPEG XL, PNG, TIFF, WebP -- Color quantization support -- Format-specific optimization options -- Silent mode for script integration -- Automatic format detection - -## Installation - -```bash -go install github.com/coalaura/ffwebp@latest -``` - -## Usage - -Basic usage: -```bash -ffwebp [options] [output] -``` - -If no output path is specified, the result will be written to stdout. - -### Options - -General options: -- `-h, --help`: Show help message -- `-s, --silent`: Do not print any output -- `-f, --format`: Output format (avif, bmp, gif, jpeg, jxl, png, tiff, webp, ico) - -Common image options: -- `-q, --quality`: Quality level for AVIF/JPEG/JXL/WebP (1-100) -- `-c, --colors`: Number of colors for quantization (0=disabled, max 256) - -WebP: -- `-x, --exact`: Preserve RGB values in transparent area -- `-l, --lossless`: Use lossless compression -- `-m, --method`: Encoder method (0=fast, 6=slower-better) - -AVIF: -- `-r, --ratio`: YCbCr subsample ratio (0=444, 1=422, 2=420, 3=440, 4=411, 5=410) -- `-p, --speed`: Encoder speed (0=fast, 10=slower-better) - -JPEG XL: -- `-e, --effort`: Encoder effort (0=fast, 10=slower-better) - -PNG: -- `-g, --level`: Compression level (0=none, 1=speed, 2=best) - -TIFF: -- `-t, --compression`: Compression type (0=none, 1=deflate, 2=lzw, 3=ccittgroup3, 4=ccittgroup4) - -## Examples - -Convert JPEG to WebP with 80% quality: -```bash -ffwebp -q 80 input.jpg output.webp -``` - -Convert PNG to WebP with lossless compression: -```bash -ffwebp -l input.png output.webp -``` - -Convert image to AVIF with custom subsample ratio: -```bash -ffwebp -f avif -r 2 -q 90 input.jpg output.avif -``` - -Quantize colors in output: -```bash -ffwebp -c 256 input.png output.png -``` +*todo* ## License diff --git a/cmd/ffwebp/main.go b/cmd/ffwebp/main.go new file mode 100644 index 0000000..3f2fc8d --- /dev/null +++ b/cmd/ffwebp/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "io" + "log" + "os" + + "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/opts" + "github.com/urfave/cli/v3" +) + +var Version = "dev" + +func main() { + log.SetOutput(os.Stderr) + + flags := codec.Flags([]cli.Flag{ + &cli.StringFlag{ + Name: "input", + Aliases: []string{"i"}, + Usage: "input file (\"-\" = stdin)", + Value: "-", + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "output file (\"-\" = stdout)", + Value: "-", + }, + &cli.StringFlag{ + Name: "codec", + Aliases: []string{"c"}, + Usage: "force output codec (jpeg, png, ...)", + }, + &cli.IntFlag{ + Name: "quality", + Aliases: []string{"q"}, + Usage: "0-100 quality for lossy codecs", + Value: 85, + }, + &cli.BoolFlag{ + Name: "lossless", + Aliases: []string{"l"}, + Usage: "force lossless mode (overrides --quality)", + }, + &cli.StringFlag{ + Name: "resize", + Aliases: []string{"r"}, + Usage: "WxH, Wx or xH (keep aspect)", + }, + }) + + app := &cli.Command{ + Name: "ffwebp", + Usage: "Convert any image format into any other image format", + Version: Version, + Flags: flags, + Action: run, + Writer: os.Stderr, + ErrWriter: os.Stderr, + } + + if err := app.Run(context.Background(), os.Args); err != nil { + log.Fatal(err) + } +} + +func run(_ context.Context, cmd *cli.Command) error { + var ( + input string + output string + + common opts.Common + + reader io.Reader = os.Stdin + writer io.Writer = os.Stdout + ) + + if input = cmd.String("input"); input != "-" { + file, err := os.OpenFile(input, os.O_RDONLY, 0) + if err != nil { + return err + } + + defer file.Close() + + reader = file + } + + if output = cmd.String("output"); output != "-" { + file, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + + defer file.Close() + + writer = file + } + + common.Quality = cmd.Int("quality") + common.Lossless = cmd.Bool("lossless") + + oCodec, err := codec.Detect(output, cmd.String("codec")) + if err != nil { + return err + } + + iCodec, reader, err := codec.Sniff(reader) + if err != nil { + return err + } + + img, err := iCodec.Decode(reader) + if err != nil { + return err + } + + resized, err := resize(img, cmd) + if err != nil { + return err + } + + return oCodec.Encode(writer, resized, common) +} diff --git a/cmd/ffwebp/resize.go b/cmd/ffwebp/resize.go new file mode 100644 index 0000000..9de1d6b --- /dev/null +++ b/cmd/ffwebp/resize.go @@ -0,0 +1,53 @@ +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/ffwebp_test.go b/ffwebp_test.go deleted file mode 100644 index 6f2e659..0000000 --- a/ffwebp_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "bytes" - "image/png" - "log" - "os" - "testing" -) - -var ( - TestFiles = []string{ - "test/image.avif", - "test/image.bmp", - "test/image.gif", - "test/image.heic", - "test/image.heif", - "test/image.ico", - "test/image.jpg", - "test/image.png", - "test/image.tif", - "test/image.tiff", - "test/image.webp", - "test/image.jxl", - } -) - -func TestFFWebP(t *testing.T) { - opts.Silent = true - opts.Format = "png" - - encoder, _ := ResolveImageEncoder() - - for _, file := range TestFiles { - log.Printf("Testing file: %s\n", file) - - in, err := os.OpenFile(file, os.O_RDONLY, 0) - if err != nil { - log.Fatalf("Failed to read %s: %v", file, err) - } - - defer in.Close() - - img, err := ReadImage(in) - if err != nil { - log.Fatalf("Failed to decode %s: %v", file, err) - } - - before := img.Bounds() - - var result bytes.Buffer - - err = encoder(&result, img) - if err != nil { - log.Fatalf("Failed to encode png image: %v", err) - } - - img, err = png.Decode(&result) - if err != nil { - log.Println(" - FAILED") - log.Fatalf("Failed to decode PNG image: %v\n", err) - } - - after := img.Bounds() - - if before.Max.X != after.Max.X || before.Max.Y != after.Max.Y { - log.Println(" - FAILED") - log.Fatalf("Invalid image (%dx%d != %dx%d) for file: %s\n", before.Max.X, before.Max.Y, after.Max.X, after.Max.Y, file) - } - - log.Println(" - PASSED") - } -} diff --git a/format.go b/format.go deleted file mode 100644 index 654bb23..0000000 --- a/format.go +++ /dev/null @@ -1,176 +0,0 @@ -package main - -import ( - "fmt" - "image" - "image/gif" - "image/jpeg" - "image/png" - "io" - "path/filepath" - "strings" - - ico "github.com/biessek/golang-ico" - "github.com/gen2brain/avif" - "github.com/gen2brain/heic" - "github.com/gen2brain/jpegxl" - "github.com/gen2brain/webp" - "golang.org/x/image/bmp" - "golang.org/x/image/tiff" -) - -var ( - OutputFormats = []string{ - "avif", - "bmp", - "gif", - "ico", - "jpeg", - "jxl", - "png", - "tiff", - "webp", - } - - InputFormats = []string{ - "avif", - "bmp", - "gif", - "heic", - "heif", - "ico", - "jpeg", - "jxl", - "png", - "tiff", - "webp", - } -) - -type Decoder func(io.Reader) (image.Image, error) - -func GetDecoderFromContent(in io.ReadSeeker) (Decoder, error) { - buffer := make([]byte, 128) - - _, err := in.Read(buffer) - if err != nil { - return nil, err - } - - if _, err := in.Seek(0, io.SeekStart); err != nil { - return nil, err - } - - if IsJPEG(buffer) { - return jpeg.Decode, nil - } else if IsPNG(buffer) { - return png.Decode, nil - } else if IsGIF(buffer) { - return gif.Decode, nil - } else if IsBMP(buffer) { - return bmp.Decode, nil - } else if IsWebP(buffer) { - return webp.Decode, nil - } else if IsTIFF(buffer) { - return tiff.Decode, nil - } else if IsICO(buffer) { - return ico.Decode, nil - } else if IsHEIC(buffer) { - return heic.Decode, nil - } else if IsAVIF(buffer) { - return avif.Decode, nil - } else if IsJpegXL(buffer) { - return jpegxl.Decode, nil - } - - return nil, fmt.Errorf("unsupported input format") -} - -func IsJPEG(buffer []byte) bool { - return len(buffer) > 2 && buffer[0] == 0xFF && buffer[1] == 0xD8 -} - -func IsPNG(buffer []byte) bool { - return len(buffer) > 8 && string(buffer[:8]) == "\x89PNG\r\n\x1a\n" -} - -func IsGIF(buffer []byte) bool { - return len(buffer) > 6 && (string(buffer[:6]) == "GIF87a" || string(buffer[:6]) == "GIF89a") -} - -func IsBMP(buffer []byte) bool { - return len(buffer) > 2 && string(buffer[:2]) == "BM" -} - -func IsICO(buffer []byte) bool { - return len(buffer) > 4 && buffer[0] == 0x00 && buffer[1] == 0x00 && buffer[2] == 0x01 && buffer[3] == 0x00 -} - -func IsWebP(buffer []byte) bool { - // Check if its VP8L - if len(buffer) > 16 && string(buffer[12:16]) == "VP8L" { - return true - } - - // Check if its WebP or RIFF WEBP - return len(buffer) > 12 && string(buffer[:4]) == "RIFF" && string(buffer[8:12]) == "WEBP" -} - -func IsAVIF(buffer []byte) bool { - return len(buffer) > 12 && string(buffer[4:8]) == "ftyp" && string(buffer[8:12]) == "avif" -} - -func IsTIFF(buffer []byte) bool { - return len(buffer) > 4 && (string(buffer[:4]) == "II*\x00" || string(buffer[:4]) == "MM\x00*") -} - -func IsHEIC(buffer []byte) bool { - return len(buffer) > 12 && string(buffer[4:8]) == "ftyp" && (string(buffer[8:12]) == "heic" || string(buffer[8:12]) == "heix") -} - -func IsJpegXL(buffer []byte) bool { - // Check for JPEG XL codestream (starts with 0xFF 0x0A) - if len(buffer) > 2 && buffer[0] == 0xFF && buffer[1] == 0x0A { - return true - } - - // Check for JPEG XL container (starts with "JXL ") - return len(buffer) > 12 && string(buffer[:4]) == "JXL " -} - -func GetFormatFromPath(path string) string { - ext := strings.ToLower(filepath.Ext(path)) - - switch ext { - case ".webp", ".riff": - return "webp" - case ".jpg", ".jpeg", ".jpe", ".jif", ".jfif": - return "jpeg" - case ".png": - return "png" - case ".gif", ".giff": - return "gif" - case ".bmp", ".dib", ".rle": - return "bmp" - case ".tiff", ".tif": - return "tiff" - case ".avif", ".avifs": - return "avif" - case ".jxl", ".jxls": - return "jxl" - case ".ico": - return "ico" - } - - return "" -} - -func IsValidOutputFormat(format string) bool { - for _, f := range OutputFormats { - if f == format { - return true - } - } - - return false -} diff --git a/go.mod b/go.mod index 826c6af..7019c96 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,10 @@ -module ffwebp +module github.com/coalaura/ffwebp -go 1.23.4 +go 1.24.2 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 - github.com/gen2brain/webp v0.4.5 - golang.org/x/image v0.20.0 + github.com/disintegration/imaging v1.6.2 + github.com/urfave/cli/v3 v3.3.8 ) -require ( - github.com/ebitengine/purego v0.7.1 // indirect - github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect - github.com/tetratelabs/wazero v1.8.0 // indirect - golang.org/x/sys v0.25.0 // indirect -) +require golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect diff --git a/go.sum b/go.sum index ecaec86..e4f7dac 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,15 @@ -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.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= -github.com/gen2brain/heic v0.3.1/go.mod h1:m2sVIf02O7wfO8mJm+PvE91lnq4QYJy2hseUon7So10= -github.com/gen2brain/jpegxl v0.3.1 h1:QAcs68WXQUQRABPVu5p5MineuqfqnVd/JRiI+s7AEE4= -github.com/gen2brain/jpegxl v0.3.1/go.mod h1:jLh4Fl9QaHkc1RsOJu4S2r20x+gSzjnuM+K8jOm4DEo= -github.com/gen2brain/webp v0.4.5 h1:wolsWSKnYfnYaWUtGLx3EfXhLWVvVx9yZGof+JNGYgY= -github.com/gen2brain/webp v0.4.5/go.mod h1:giUCZaJt7D8ae9AjSq4gC3QKUuA9SD8LZy0o2zcWxMI= -github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= -github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= -github.com/tetratelabs/wazero v1.8.0 h1:iEKu0d4c2Pd+QSRieYbnQC9yiFlMS9D+Jr0LsRmcF4g= -github.com/tetratelabs/wazero v1.8.0/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= -golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= -golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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= +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/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/help.go b/help.go deleted file mode 100644 index bf9e22e..0000000 --- a/help.go +++ /dev/null @@ -1,67 +0,0 @@ -package main - -import ( - "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("ffwebp [options] [output]\n") - - arguments.ShowHelp(true) - - b := arguments.NewBuilder(true) - - b.WriteRune('\n') - b.Mute() - b.WriteString(" - ") - b.Name() - b.WriteString("Input formats") - b.Mute() - b.WriteString(": ") - values(b, InputFormats) - - b.WriteRune('\n') - b.Mute() - b.WriteString(" - ") - b.Name() - b.WriteString("Output formats") - b.Mute() - b.WriteString(": ") - values(b, OutputFormats) - - println(b.String()) - - os.Exit(0) -} - -func values(b *arguments.Builder, v []string) { - for i, value := range v { - if i > 0 { - b.Mute() - b.WriteString(", ") - } - - b.Value() - b.WriteString(value) - } - - b.Reset() -} diff --git a/image.go b/image.go deleted file mode 100644 index ae44f31..0000000 --- a/image.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "fmt" - "image" - "image/gif" - "image/jpeg" - "io" - - ico "github.com/biessek/golang-ico" - "github.com/gen2brain/avif" - "github.com/gen2brain/jpegxl" - "github.com/gen2brain/webp" - "golang.org/x/image/bmp" - "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 { - return nil, err - } - - return decoder(input) -} - -func ResolveImageEncoder() (Encoder, error) { - table := NewOptionsTable() - - switch opts.Format { - case "webp": - options := GetWebPOptions() - - table.AddWebPOptions(options) - - return func(output io.Writer, img image.Image) error { - return webp.Encode(output, img, options) - }, nil - case "jpeg": - options := GetJpegOptions() - - table.AddJpegOptions(options) - - return func(output io.Writer, img image.Image) error { - return jpeg.Encode(output, img, options) - }, nil - case "png": - encoder := GetPNGOptions() - - table.AddPNGOptions(encoder) - - return func(output io.Writer, img image.Image) error { - return encoder.Encode(output, img) - }, nil - case "gif": - options := GetGifOptions() - - 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() - - table.AddTiffOptions(options) - - return func(output io.Writer, img image.Image) error { - return tiff.Encode(output, img, options) - }, nil - case "avif": - options := GetAvifOptions() - - table.AddAvifOptions(options) - - return func(output io.Writer, img image.Image) error { - return avif.Encode(output, img, options) - }, nil - case "jxl": - options := GetJxlOptions() - - table.AddJxlOptions(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 nil, fmt.Errorf("unsupported output format: %s", opts.Format) -} diff --git a/internal/codec/codec.go b/internal/codec/codec.go new file mode 100644 index 0000000..d3e3ffd --- /dev/null +++ b/internal/codec/codec.go @@ -0,0 +1,50 @@ +package codec + +import ( + "image" + "io" + + "github.com/coalaura/ffwebp/internal/opts" + "github.com/urfave/cli/v3" +) + +type Codec interface { + Name() string + + Flags([]cli.Flag) []cli.Flag + Extensions() []string + + Sniff(io.ReaderAt) (int, error) + Decode(io.Reader) (image.Image, error) + Encode(io.Writer, image.Image, opts.Common) error +} + +var codecs = map[string]Codec{} + +func Register(c Codec) { + codecs[c.Name()] = c +} + +func Flags(flags []cli.Flag) []cli.Flag { + for _, codec := range codecs { + flags = codec.Flags(flags) + } + + return flags +} + +func Get(name string) (Codec, bool) { + c, ok := codecs[name] + + return c, ok +} + +func All() []Codec { + out := make([]Codec, 0, len(codecs)) + + for _, c := range codecs { + out = append(out, c) + } + + return out +} diff --git a/internal/codec/detect.go b/internal/codec/detect.go new file mode 100644 index 0000000..11598d1 --- /dev/null +++ b/internal/codec/detect.go @@ -0,0 +1,72 @@ +package codec + +import ( + "bytes" + "errors" + "fmt" + "io" + "path/filepath" + "strings" +) + +func Sniff(reader io.Reader) (Codec, io.Reader, error) { + buf, err := io.ReadAll(reader) + if err != nil { + return nil, nil, err + } + + ra := bytes.NewReader(buf) + + var ( + guess Codec + best int + ) + + for _, codec := range codecs { + confidence, err := codec.Sniff(ra) + if err != nil { + return nil, nil, err + } + + if confidence > best { + best = confidence + guess = codec + } + } + + if guess == nil { + return nil, nil, errors.New("unknown format") + } + + return guess, bytes.NewReader(buf), nil +} + +func Detect(output, override string) (Codec, error) { + if override != "" { + codec, ok := codecs[override] + if !ok { + return nil, fmt.Errorf("unsupported output codec: %q", override) + } + + return codec, nil + } + + if output == "-" { + return nil, errors.New("missing codec for output") + } + + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(output), ".")) + if ext == "" { + return nil, fmt.Errorf("output filename %q has no extension", output) + } + + for _, codec := range codecs { + for _, alias := range codec.Extensions() { + if ext == strings.ToLower(alias) { + return codec, nil + } + } + } + + return nil, fmt.Errorf("unsupported or unknown file extension: %q", ext) +} diff --git a/internal/codec/jpeg/jpeg.go b/internal/codec/jpeg/jpeg.go new file mode 100644 index 0000000..c34c944 --- /dev/null +++ b/internal/codec/jpeg/jpeg.go @@ -0,0 +1,56 @@ +package jpeg + +import ( + "bytes" + "image" + "image/jpeg" + "io" + + "github.com/coalaura/ffwebp/internal/codec" + "github.com/coalaura/ffwebp/internal/opts" + "github.com/urfave/cli/v3" +) + +type impl struct{} + +func init() { + codec.Register(impl{}) +} + +func (impl) Name() string { + return "jpeg" +} + +func (impl) Extensions() []string { + return []string{"jpg", "jpeg", "jpe"} +} + +func (impl) Flags(flags []cli.Flag) []cli.Flag { + return flags +} + +func (impl) Sniff(reader io.ReaderAt) (int, error) { + marker := []byte{0xFF, 0xD8, 0xFF} + + buf := make([]byte, 3) + + if _, err := reader.ReadAt(buf, 0); err != nil { + return 0, err + } + + if bytes.Equal(buf, marker) { + return 100, nil + } + + return 0, nil +} + +func (impl) Decode(reader io.Reader) (image.Image, error) { + return jpeg.Decode(reader) +} + +func (impl) Encode(writer io.Writer, img image.Image, options opts.Common) error { + return jpeg.Encode(writer, img, &jpeg.Options{ + Quality: options.Quality, + }) +} diff --git a/internal/opts/opts.go b/internal/opts/opts.go new file mode 100644 index 0000000..473cf3a --- /dev/null +++ b/internal/opts/opts.go @@ -0,0 +1,16 @@ +package opts + +type Common struct { + Quality int + Lossless bool +} + +func (c *Common) FillDefaults() { + if c.Quality == 0 { + c.Quality = 85 + } + + if c.Lossless { + c.Quality = 100 + } +} diff --git a/log.go b/log.go deleted file mode 100644 index 4eeb960..0000000 --- a/log.go +++ /dev/null @@ -1,41 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/coalaura/arguments" -) - -var ( - silent bool -) - -func debug(msg string) { - if silent { - return - } - - b := arguments.NewBuilder(true) - - b.Mute() - b.WriteString("# ") - b.WriteString(msg) - - println(b.String()) -} - -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 deleted file mode 100644 index c6c51f4..0000000 --- a/main.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "os" -) - -func main() { - header() - parse() - - encoder, err := ResolveImageEncoder() - must(err) - - debug("Reading input image...") - - in, err := os.OpenFile(opts.Input, os.O_RDONLY, 0) - must(err) - - defer in.Close() - - var out *os.File - - if opts.Output == "" { - opts.Silent = true - - out = os.Stdout - } else { - out, err = os.OpenFile(opts.Output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) - must(err) - - defer out.Close() - } - - debug("Decoding input image...") - - img, err := ReadImage(in) - must(err) - - // Write image - debug("Encoding output image...") - - img = Quantize(img) - - err = encoder(out, img) - must(err) - - debug("Completed.") -} diff --git a/options.go b/options.go deleted file mode 100644 index dc9db5c..0000000 --- a/options.go +++ /dev/null @@ -1,279 +0,0 @@ -package main - -import ( - "image" - "image/gif" - "image/jpeg" - "image/png" - - "github.com/coalaura/arguments" - "github.com/gen2brain/avif" - "github.com/gen2brain/jpegxl" - "github.com/gen2brain/webp" - "golang.org/x/image/tiff" -) - -type Options struct { - Help bool - Input string - Output string - - Silent bool - NumColors int - Effort int - Format string - Lossless bool - Method int - Ratio int - Quality int - Exact bool - Compression int - Level int - Speed int -} - -var opts = Options{ - Help: false, - Input: "", - Output: "", - - Silent: false, - NumColors: 0, - Effort: 10, - Format: "", - Lossless: false, - Method: 6, - Ratio: 0, - Quality: 90, - Exact: false, - Compression: 2, - Level: 2, - Speed: 0, -} - -func parse() { - // General options - arguments.Register("help", 'h', &opts.Help).WithHelp("Show this help message") - arguments.Register("silent", 's', &opts.Silent).WithHelp("Do not print any output") - arguments.Register("format", 'f', &opts.Format).WithHelp("Output format (avif, bmp, gif, jpeg, jxl, png, tiff, webp, ico)") - - // 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)") - - // JXL - arguments.Register("effort", 'e', &opts.Effort).WithHelp("[jxl] Encoder effort level (0=fast, 10=slower-better)") - - // PNG - arguments.Register("level", 'g', &opts.Level).WithHelp("[png] Compression level (0=no-compression, 1=best-speed, 2=best-compression)") - - // TIFF - arguments.Register("compression", 't', &opts.Compression).WithHelp("[tiff] Compression type (0=uncompressed, 1=deflate, 2=lzw, 3=ccittgroup3, 4=ccittgroup4)") - - // WebP - arguments.Register("exact", 'x', &opts.Exact).WithHelp("[webp] Preserve RGB values in transparent area") - 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)") - - must(arguments.Parse()) - - help() - - if len(arguments.Args) < 1 { - fatalf("Missing input file") - } - - opts.Input = arguments.Args[0] - - if len(arguments.Args) > 1 { - opts.Output = arguments.Args[1] - } - - if opts.Format != "" && !IsValidOutputFormat(opts.Format) { - fatalf("Invalid output format: %s", opts.Format) - } - - // Resolve format from output file - if opts.Format == "" && opts.Output != "" { - opts.Format = GetFormatFromPath(opts.Output) - } - - // Otherwise resolve format from input file - if opts.Format == "" { - opts.Format = GetFormatFromPath(opts.Input) - } - - // Or default to webp - if opts.Format == "" { - opts.Format = "webp" - } else if opts.Format == "jpg" { - opts.Format = "jpeg" - } - - // NumColors must be between 0 and 256 - if opts.NumColors < 0 || opts.NumColors > 256 { - opts.NumColors = 0 - } - - // Effort must be between 0 and 10 - if opts.Effort < 0 || opts.Effort > 10 { - opts.Effort = 10 - } - - // Method must be between 0 and 6 - if opts.Method < 0 || opts.Method > 6 { - opts.Method = 6 - } - - // Quality must be between 1 and 100 - if opts.Quality < 1 || opts.Quality > 100 { - opts.Quality = 90 - } - - // Ratio must be between 0 and 5 - if opts.Ratio < 0 || opts.Ratio > 5 { - opts.Ratio = 0 - } - - // Compression must be between 0 and 4 - if opts.Compression < 0 || opts.Compression > 4 { - opts.Compression = 2 - } - - // Level must be between 0 and 2 - if opts.Level < 0 || opts.Level > 2 { - opts.Level = 2 - } -} - -func GetWebPOptions() webp.Options { - return webp.Options{ - Lossless: opts.Lossless, - Quality: opts.Quality, - Method: opts.Method, - Exact: opts.Exact, - } -} - -func GetJpegOptions() *jpeg.Options { - return &jpeg.Options{ - Quality: opts.Quality, - } -} - -func GetPNGOptions() *png.Encoder { - return &png.Encoder{ - CompressionLevel: GetPNGCompressionLevel(), - } -} - -func GetGifOptions() *gif.Options { - return &gif.Options{ - NumColors: opts.NumColors, - } -} - -func GetTiffOptions() *tiff.Options { - return &tiff.Options{ - Compression: GetTiffCompressionType(), - } -} - -func GetAvifOptions() avif.Options { - return avif.Options{ - Quality: opts.Quality, - QualityAlpha: opts.Quality, - Speed: opts.Speed, - ChromaSubsampling: GetAvifYCbCrSubsampleRatio(), - } -} - -func GetJxlOptions() jpegxl.Options { - return jpegxl.Options{ - Quality: opts.Quality, - Effort: opts.Effort, - } -} - -func GetTiffCompressionType() tiff.CompressionType { - switch opts.Compression { - case 0: - return tiff.Uncompressed - case 1: - return tiff.Deflate - case 2: - return tiff.LZW - case 3: - return tiff.CCITTGroup3 - case 4: - return tiff.CCITTGroup4 - } - - return tiff.Deflate -} - -func TiffCompressionTypeToString(compression tiff.CompressionType) string { - switch compression { - case tiff.Uncompressed: - return "uncompressed" - case tiff.Deflate: - return "deflate" - case tiff.LZW: - return "lzw" - case tiff.CCITTGroup3: - return "ccittgroup3" - case tiff.CCITTGroup4: - return "ccittgroup4" - default: - return "unknown" - } -} - -func GetAvifYCbCrSubsampleRatio() image.YCbCrSubsampleRatio { - switch opts.Ratio { - case 0: - return image.YCbCrSubsampleRatio444 - case 1: - return image.YCbCrSubsampleRatio422 - case 2: - return image.YCbCrSubsampleRatio420 - case 3: - return image.YCbCrSubsampleRatio440 - case 4: - return image.YCbCrSubsampleRatio411 - case 5: - return image.YCbCrSubsampleRatio410 - } - - return image.YCbCrSubsampleRatio444 -} - -func GetPNGCompressionLevel() png.CompressionLevel { - switch opts.Level { - case 0: - return png.NoCompression - case 1: - return png.BestSpeed - case 2: - return png.BestCompression - } - - return png.BestCompression -} - -func PNGCompressionLevelToString(level png.CompressionLevel) string { - switch level { - case png.NoCompression: - return "no-compression" - case png.BestSpeed: - return "best-speed" - case png.BestCompression: - return "best-compression" - default: - return "unknown" - } -} diff --git a/palette.go b/palette.go deleted file mode 100644 index ec34a5c..0000000 --- a/palette.go +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 344edf5..0000000 --- a/table.go +++ /dev/null @@ -1,134 +0,0 @@ -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() -} diff --git a/version.go b/version.go deleted file mode 100644 index c3205e5..0000000 --- a/version.go +++ /dev/null @@ -1,3 +0,0 @@ -package main - -const Version = " dev"