Files
go-llm/utils/compress_image.go

130 lines
3.5 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package utils
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"net/http"
"golang.org/x/image/draw"
)
// CompressImage takes a base64encoded image (JPEG, PNG or GIF) and returns
// a base64encoded version that is at most maxLength in size, or an error.
func CompressImage(b64 string, maxLength int) (string, error) {
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", fmt.Errorf("base64 decode: %w", err)
}
if len(raw) <= maxLength {
return b64, nil // small enough already
}
switch mime := http.DetectContentType(raw); mime {
case "image/gif":
return compressGIF(raw, maxLength)
default: // jpeg, png, webp, etc. → treat as raster
return compressRaster(raw, maxLength)
}
}
// ---------- Raster path (jpeg / png / singleframe gif) ----------
func compressRaster(src []byte, maxLength int) (string, error) {
img, _, err := image.Decode(bytes.NewReader(src))
if err != nil {
return "", fmt.Errorf("decode raster: %w", err)
}
quality := 95
for {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
return "", fmt.Errorf("jpeg encode: %w", err)
}
if buf.Len() <= maxLength {
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
if quality > 20 {
quality -= 5
continue
}
// downscale 80%
b := img.Bounds()
if b.Dx() < 100 || b.Dy() < 100 {
return "", fmt.Errorf("cannot compress below %.02fMiB without destroying image", float64(maxLength)/1048576.0)
}
dst := image.NewRGBA(image.Rect(0, 0, int(float64(b.Dx())*0.8), int(float64(b.Dy())*0.8)))
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
img = dst
quality = 95 // restart ladder
}
}
// ---------- Animated GIF path ----------
func compressGIF(src []byte, maxLength int) (string, error) {
g, err := gif.DecodeAll(bytes.NewReader(src))
if err != nil {
return "", fmt.Errorf("gif decode: %w", err)
}
for {
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, g); err != nil {
return "", fmt.Errorf("gif encode: %w", err)
}
if buf.Len() <= maxLength {
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// downscale every frame by 80%
w, h := g.Config.Width, g.Config.Height
if w < 100 || h < 100 {
return "", fmt.Errorf("cannot compress animated GIF below 5MiB without excessive quality loss")
}
nw, nh := int(float64(w)*0.8), int(float64(h)*0.8)
for i, frm := range g.Image {
// convert paletted frame → RGBA for scaling
rgba := image.NewRGBA(frm.Bounds())
draw.Draw(rgba, rgba.Bounds(), frm, frm.Bounds().Min, draw.Src)
// scaled destination
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), rgba, rgba.Bounds(), draw.Over, nil)
// quantize back to paletted using default encoder quantizer
paletted := image.NewPaletted(dst.Bounds(), nil)
draw.FloydSteinberg.Draw(paletted, paletted.Bounds(), dst, dst.Bounds().Min)
g.Image[i] = paletted
}
g.Config.Width, g.Config.Height = nw, nh
// loop back and test size again …
}
}
// ---------- Helpers for precise MIME decodes (optional) ----------
func decode(r *bytes.Reader, mime string) (image.Image, error) {
switch mime {
case "image/jpeg":
return jpeg.Decode(r)
case "image/png":
return png.Decode(r)
case "image/gif":
// for singleframe GIFs only call DecodeAll in caller if animated
return gif.Decode(r)
default:
i, _, err := image.Decode(r)
return i, err // let the stdlib guess the format
}
}