| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "image" |
| 6 | _ "image/gif" // register gif decoder |
| 7 | "image/jpeg" |
| 8 | "image/png" |
| 9 | |
| 10 | xdraw "golang.org/x/image/draw" |
| 11 | _ "golang.org/x/image/webp" // register webp decoder |
| 12 | ) |
| 13 | |
| 14 | // maxVisionDim caps the longest image side sent to a model. OpenAI and Anthropic |
| 15 | // downscale to roughly this server-side anyway, so a larger upload only wastes |
| 16 | // request bytes and image tokens without adding fidelity. |
| 17 | const maxVisionDim = 1568 |
| 18 | |
| 19 | // maxDecodePixels guards against decompression-bomb attachments: a tiny file can |
| 20 | // declare enormous dimensions. Beyond this we skip decoding and send as-is (still |
| 21 | // bounded by the 10 MB file cap). |
| 22 | const maxDecodePixels = 50_000_000 |
| 23 | |
| 24 | // compressForVision downscales an oversized image to maxVisionDim and re-encodes |
| 25 | // it — PNG/GIF stay lossless (screenshots, text, transparency), JPEG/WebP go to |
| 26 | // JPEG. Best-effort: an undecodable format, a decode/encode failure, or an image |
| 27 | // already within budget returns the original bytes and mime unchanged. |
| 28 | func compressForVision(raw []byte, mime string) ([]byte, string) { |
| 29 | switch mime { |
| 30 | case "image/png", "image/jpeg", "image/gif", "image/webp": |
| 31 | default: |
| 32 | return raw, mime // bmp/tiff/svg: no decoder wired, send original |
| 33 | } |
| 34 | cfg, _, err := image.DecodeConfig(bytes.NewReader(raw)) |
| 35 | if err != nil || cfg.Width*cfg.Height > maxDecodePixels { |
| 36 | return raw, mime |
| 37 | } |
| 38 | if cfg.Width <= maxVisionDim && cfg.Height <= maxVisionDim { |
| 39 | return raw, mime // within budget — no point re-encoding |
| 40 | } |
| 41 | src, _, err := image.Decode(bytes.NewReader(raw)) |
| 42 | if err != nil { |
| 43 | return raw, mime |
| 44 | } |
| 45 | w, h := scaledDims(cfg.Width, cfg.Height, maxVisionDim) |
| 46 | dst := image.NewRGBA(image.Rect(0, 0, w, h)) |
| 47 | xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil) |
| 48 | |
| 49 | var buf bytes.Buffer |
| 50 | if mime == "image/png" || mime == "image/gif" { |
| 51 | if err := png.Encode(&buf, dst); err != nil { |
| 52 | return raw, mime |
| 53 | } |
| 54 | return buf.Bytes(), "image/png" |
| 55 | } |
| 56 | if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil { |
| 57 | return raw, mime |
| 58 | } |
| 59 | return buf.Bytes(), "image/jpeg" |
| 60 | } |
| 61 | |
| 62 | // scaledDims returns dimensions with the longest side clamped to m, preserving |
| 63 | // aspect ratio (each side at least 1px). |
| 64 | func scaledDims(w, h, m int) (int, int) { |
| 65 | if w >= h { |
| 66 | nh := h * m / w |
| 67 | if nh < 1 { |
| 68 | nh = 1 |
| 69 | } |
| 70 | return m, nh |
| 71 | } |
| 72 | nw := w * m / h |
| 73 | if nw < 1 { |
| 74 | nw = 1 |
| 75 | } |
| 76 | return nw, m |
| 77 | } |
| 78 |