1
0
mirror of https://github.com/coalaura/ffwebp.git synced 2025-07-17 22:04:35 +00:00
This commit is contained in:
Laura
2025-06-19 12:25:32 +02:00
parent de0359cacc
commit c09beb0d77
20 changed files with 396 additions and 1068 deletions

View File

@ -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

View File

@ -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] <input> [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

127
cmd/ffwebp/main.go Normal file
View File

@ -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)
}

53
cmd/ffwebp/resize.go Normal file
View File

@ -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
}

View File

@ -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")
}
}

176
format.go
View File

@ -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
}

21
go.mod
View File

@ -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

39
go.sum
View File

@ -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=

67
help.go
View File

@ -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] <input> [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()
}

102
image.go
View File

@ -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)
}

50
internal/codec/codec.go Normal file
View File

@ -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
}

72
internal/codec/detect.go Normal file
View File

@ -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)
}

View File

@ -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,
})
}

16
internal/opts/opts.go Normal file
View File

@ -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
}
}

41
log.go
View File

@ -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)
}

48
main.go
View File

@ -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.")
}

View File

@ -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"
}
}

View File

@ -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
}

134
table.go
View File

@ -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()
}

View File

@ -1,3 +0,0 @@
package main
const Version = " dev"