mirror of
https://github.com/coalaura/ffwebp.git
synced 2025-07-18 06:14:34 +00:00
rewrite
This commit is contained in:
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@ -29,13 +29,11 @@ jobs:
|
|||||||
- name: Set up Environment
|
- name: Set up Environment
|
||||||
run: |
|
run: |
|
||||||
mkdir -p build
|
mkdir -p build
|
||||||
echo "package main" > version.go
|
|
||||||
echo "const Version = \"${{ github.ref_name }}\"" >> version.go
|
|
||||||
|
|
||||||
- name: Build for ${{ matrix.goos }}_${{ matrix.goarch }}
|
- name: Build for ${{ matrix.goos }}_${{ matrix.goarch }}
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; else EXT=""; fi
|
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
|
- name: Upload build artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
77
README.md
77
README.md
@ -1,81 +1,6 @@
|
|||||||
# ffwebp
|
# ffwebp
|
||||||
|
|
||||||
A fast and flexible command-line image conversion tool supporting multiple formats and advanced compression options.
|
*todo*
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
127
cmd/ffwebp/main.go
Normal file
127
cmd/ffwebp/main.go
Normal 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
53
cmd/ffwebp/resize.go
Normal 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
|
||||||
|
}
|
@ -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
176
format.go
@ -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
21
go.mod
@ -1,21 +1,10 @@
|
|||||||
module ffwebp
|
module github.com/coalaura/ffwebp
|
||||||
|
|
||||||
go 1.23.4
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/biessek/golang-ico v0.0.0-20180326222316-d348d9ea4670
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/coalaura/arguments v1.5.2
|
github.com/urfave/cli/v3 v3.3.8
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||||
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
|
|
||||||
)
|
|
||||||
|
39
go.sum
39
go.sum
@ -1,24 +1,15 @@
|
|||||||
github.com/biessek/golang-ico v0.0.0-20180326222316-d348d9ea4670 h1:FQPKKjDhzG0T4ew6dm6MGrXb4PRAi8ZmTuYuxcF62BM=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/biessek/golang-ico v0.0.0-20180326222316-d348d9ea4670/go.mod h1:iRWAFbKXMMkVQyxZ1PfGlkBr1TjATx1zy2MRprV7A3Q=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/coalaura/arguments v1.5.2 h1:hRLKo6XmAzCDOS/unCUVAIYl3WU/i6QX59nBh0T31cw=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/coalaura/arguments v1.5.2/go.mod h1:F5cdI+Gn1qi5K6qqvAdxdTD2TXkny+gTKU0o6NN1MlU=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 h1:BBade+JlV/f7JstZ4pitd4tHhpN+w+6I+LyOS7B4fyU=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4/go.mod h1:H7chHJglrhPPzetLdzBleF8d22WYOv7UM/lEKYiwlKM=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/gen2brain/avif v0.3.2 h1:XUR0CBl5n4ISFJE8/pc1RMEKt5KUVoW8InctN+M7+DQ=
|
github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E=
|
||||||
github.com/gen2brain/avif v0.3.2/go.mod h1:tdL2sV6oOJXBZZvT5iP55VEM1X2c3/yJmYKMJTl8fXg=
|
github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
|
||||||
github.com/gen2brain/heic v0.3.1 h1:ClY5YTdXdIanw7pe9ZVUM9XcsqH6CCCa5CZBlm58qOs=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||||
github.com/gen2brain/heic v0.3.1/go.mod h1:m2sVIf02O7wfO8mJm+PvE91lnq4QYJy2hseUon7So10=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
github.com/gen2brain/jpegxl v0.3.1 h1:QAcs68WXQUQRABPVu5p5MineuqfqnVd/JRiI+s7AEE4=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
github.com/gen2brain/jpegxl v0.3.1/go.mod h1:jLh4Fl9QaHkc1RsOJu4S2r20x+gSzjnuM+K8jOm4DEo=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
github.com/gen2brain/webp v0.4.5 h1:wolsWSKnYfnYaWUtGLx3EfXhLWVvVx9yZGof+JNGYgY=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
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=
|
|
||||||
|
67
help.go
67
help.go
@ -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
102
image.go
@ -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
50
internal/codec/codec.go
Normal 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
72
internal/codec/detect.go
Normal 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)
|
||||||
|
}
|
56
internal/codec/jpeg/jpeg.go
Normal file
56
internal/codec/jpeg/jpeg.go
Normal 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
16
internal/opts/opts.go
Normal 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
41
log.go
@ -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
48
main.go
@ -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.")
|
|
||||||
}
|
|
279
options.go
279
options.go
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
26
palette.go
26
palette.go
@ -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
134
table.go
@ -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()
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
const Version = " dev"
|
|
Reference in New Issue
Block a user