v2 is a new Go module (v2/) with a dramatically simpler API: - Unified Message type (no more Input marker interface) - Define[T] for ergonomic tool creation with standard context.Context - Chat session with automatic tool-call loop (agent loop) - Streaming via pull-based StreamReader - MCP one-call connect (MCPStdioServer, MCPHTTPServer, MCPSSEServer) - Middleware support (logging, retry, timeout, usage tracking) - Decoupled JSON Schema (map[string]any, no provider coupling) - Sample tools: WebSearch, Browser, Exec, ReadFile, WriteFile, HTTP - Providers: OpenAI, Anthropic, Google (all with streaming) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
106 lines
2.8 KiB
Go
106 lines
2.8 KiB
Go
// Package imageutil provides image compression utilities.
|
|
package imageutil
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"image"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
_ "image/png" // register PNG decoder
|
|
"net/http"
|
|
|
|
"golang.org/x/image/draw"
|
|
)
|
|
|
|
// CompressImage takes a base-64-encoded image (JPEG, PNG or GIF) and returns
|
|
// a base-64-encoded version that is at most maxLength bytes, along with the MIME type.
|
|
func CompressImage(b64 string, maxLength int) (string, string, error) {
|
|
raw, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("base64 decode: %w", err)
|
|
}
|
|
|
|
mime := http.DetectContentType(raw)
|
|
if len(raw) <= maxLength {
|
|
return b64, mime, nil
|
|
}
|
|
|
|
switch mime {
|
|
case "image/gif":
|
|
return compressGIF(raw, maxLength)
|
|
default:
|
|
return compressRaster(raw, maxLength)
|
|
}
|
|
}
|
|
|
|
func compressRaster(src []byte, maxLength int) (string, 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()), "image/jpeg", nil
|
|
}
|
|
|
|
if quality > 20 {
|
|
quality -= 5
|
|
continue
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
func compressGIF(src []byte, maxLength int) (string, 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()), "image/gif", nil
|
|
}
|
|
|
|
w, h := g.Config.Width, g.Config.Height
|
|
if w < 100 || h < 100 {
|
|
return "", "", fmt.Errorf("cannot compress animated GIF below %.02fMiB", float64(maxLength)/1048576.0)
|
|
}
|
|
|
|
nw, nh := int(float64(w)*0.8), int(float64(h)*0.8)
|
|
for i, frm := range g.Image {
|
|
rgba := image.NewRGBA(frm.Bounds())
|
|
draw.Draw(rgba, rgba.Bounds(), frm, frm.Bounds().Min, draw.Src)
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
|
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), rgba, rgba.Bounds(), draw.Over, nil)
|
|
|
|
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
|
|
}
|
|
}
|