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"