Prerequisite for seed-packet capture (#81). majordomo's media pipeline is stdlib-based and cannot decode HEIC or webp — and HEIC is the iPhone camera default, so the capture feature's very first input would fail on the device that motivates it. Normalizing at the upload boundary means everything downstream only ever sees JPEG. internal/imagenorm.Normalize(reader, opts) decodes any of jpeg/png/heic/webp, downscales to fit MaxDim (2048, ollama-cloud's limit) on the longest edge with Catmull-Rom (sharp on the text a packet is mostly made of), and re-encodes JPEG. CGO stays off: github.com/gen2brain/heic runs libheif as WASM via wazero (pure Go), golang.org/x/image/webp is pure Go. Both register with image.Decode. The ~5 MB these add only links into the binary when #81 imports this package — it's not pulled into cmd/pansy yet, so main is unchanged in size for now. Guards against hostile uploads: input capped at MaxBytes (ErrTooLarge) before a full read, and the decoded pixel count checked from DecodeConfig BEFORE the bitmap is allocated, so a small file claiming a huge canvas (a decompression bomb) is refused up front. TestNormalizeAllFormats round-trips all four formats to a valid JPEG — its real job is catching a dropped blank import, where the intuition-breaking failure is that the COMMON format (png) breaks while the exotic one (heic) still works. heic/webp fixtures live in testdata (Go has no encoder for them); the webp is a known-good 16x16 from Python's stdlib test corpus, verified to decode. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
158 lines
5.7 KiB
Go
158 lines
5.7 KiB
Go
// Package imagenorm normalizes an uploaded image to a JPEG the rest of pansy
|
||
// (and majordomo's vision path) can rely on, decoding the formats a phone
|
||
// actually produces.
|
||
//
|
||
// # Why this exists
|
||
//
|
||
// majordomo's own media pipeline is stdlib-based, so it cannot decode HEIC or
|
||
// WebP — and HEIC is the iPhone camera default. The seed-packet feature's very
|
||
// first input is "a photo from my phone", so without this the feature fails on
|
||
// the exact device that motivates it. Normalizing at the upload boundary means
|
||
// everything downstream only ever sees JPEG.
|
||
//
|
||
// # The CGO constraint
|
||
//
|
||
// pansy is CGO_ENABLED=0 (a single static binary — the reason modernc/sqlite was
|
||
// chosen over the C one), so a libheif *binding* is out. github.com/gen2brain/heic
|
||
// runs libheif as WebAssembly via wazero: pure Go, no cgo, and it registers with
|
||
// image.Decode like any other format. golang.org/x/image/webp is pure Go too.
|
||
//
|
||
// # Import-driven registry
|
||
//
|
||
// Go's image decoders register via blank imports, and the failure mode is
|
||
// backwards from intuition: forget "image/png" and PNG uploads fail with
|
||
// "unknown format" while the exotic HEIC still works. So the blank imports below
|
||
// are load-bearing, and TestNormalize exercises all four formats to keep them so.
|
||
package imagenorm
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"image"
|
||
"image/jpeg"
|
||
"io"
|
||
|
||
"golang.org/x/image/draw"
|
||
|
||
// Decoders, registered with image.Decode by side effect. All four matter:
|
||
// jpeg/png are the common cases, heic is the iPhone default, webp is common
|
||
// on the web. Dropping any one silently breaks that format's uploads.
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
|
||
_ "github.com/gen2brain/heic"
|
||
_ "golang.org/x/image/webp"
|
||
)
|
||
|
||
// Defaults chosen for the seed-packet path against ollama-cloud's limits (8
|
||
// images, 20 MiB, 2048px, jpeg+png). We re-encode to JPEG well under all of them.
|
||
const (
|
||
// DefaultMaxDim is the longest-edge ceiling. 2048 matches ollama-cloud's
|
||
// MaxDim; anything larger is downscaled. A packet photo has plenty of detail
|
||
// left at 2048.
|
||
DefaultMaxDim = 2048
|
||
// DefaultMaxBytes caps the *input* we will read. A phone photo is 3–8 MB; 25
|
||
// MiB leaves headroom for a large HEIC without inviting a decompression bomb
|
||
// as an unbounded read. The re-encoded output is far smaller.
|
||
DefaultMaxBytes = 25 << 20
|
||
// maxDecodePixels bounds the decoded bitmap regardless of input byte size — a
|
||
// small file can claim enormous dimensions (a decompression bomb). 100 MP is
|
||
// ~400 MB at 4 bytes/px decoded, already generous for any real photo.
|
||
maxDecodePixels = 100_000_000
|
||
// jpegQuality for the normalized output. 85 is visually clean and keeps the
|
||
// file small; the model reads text off it, not fine gradients.
|
||
jpegQuality = 85
|
||
)
|
||
|
||
// Options tunes Normalize. The zero value uses the Default* constants.
|
||
type Options struct {
|
||
MaxDim int // longest edge; 0 → DefaultMaxDim
|
||
MaxBytes int // input read cap; 0 → DefaultMaxBytes
|
||
}
|
||
|
||
func (o Options) maxDim() int {
|
||
if o.MaxDim > 0 {
|
||
return o.MaxDim
|
||
}
|
||
return DefaultMaxDim
|
||
}
|
||
|
||
func (o Options) maxBytes() int {
|
||
if o.MaxBytes > 0 {
|
||
return o.MaxBytes
|
||
}
|
||
return DefaultMaxBytes
|
||
}
|
||
|
||
// ErrTooLarge means the input exceeded the byte cap or decoded to an absurd
|
||
// pixel count. ErrUnsupported means the bytes weren't a decodable image format.
|
||
var (
|
||
ErrTooLarge = fmt.Errorf("imagenorm: image too large")
|
||
ErrUnsupported = fmt.Errorf("imagenorm: unsupported or corrupt image")
|
||
)
|
||
|
||
// Normalize reads an image of any supported format (JPEG, PNG, HEIC, WebP),
|
||
// downscales it to fit opts.MaxDim on its longest edge, and returns it re-encoded
|
||
// as JPEG. It also reports the decoded format name (e.g. "heic"), which is handy
|
||
// for logging what a phone actually sent.
|
||
//
|
||
// It reads at most opts.MaxBytes and refuses a bitmap over maxDecodePixels, so a
|
||
// hostile upload can neither exhaust memory by byte count nor by claimed
|
||
// dimensions. A read/format error maps to ErrUnsupported; an over-limit input to
|
||
// ErrTooLarge.
|
||
func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) {
|
||
// Cap the read at MaxBytes+1 so we can tell "exactly at the cap" from "over".
|
||
limited := io.LimitReader(r, int64(opts.maxBytes())+1)
|
||
raw, err := io.ReadAll(limited)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("imagenorm: read: %w", err)
|
||
}
|
||
if len(raw) > opts.maxBytes() {
|
||
return nil, "", ErrTooLarge
|
||
}
|
||
|
||
// Check dimensions BEFORE a full decode, so a decompression bomb is refused
|
||
// before it allocates its bitmap.
|
||
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
|
||
if err != nil {
|
||
return nil, "", ErrUnsupported
|
||
}
|
||
if int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
|
||
return nil, format, ErrTooLarge
|
||
}
|
||
|
||
img, _, err := image.Decode(bytes.NewReader(raw))
|
||
if err != nil {
|
||
return nil, format, ErrUnsupported
|
||
}
|
||
|
||
img = downscale(img, opts.maxDim())
|
||
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||
return nil, format, fmt.Errorf("imagenorm: encode jpeg: %w", err)
|
||
}
|
||
return buf.Bytes(), format, nil
|
||
}
|
||
|
||
// downscale returns img shrunk so its longest edge is at most maxDim, preserving
|
||
// aspect ratio. An image already within bounds is returned unchanged (no
|
||
// re-sampling, no quality loss beyond the JPEG round-trip). Uses Catmull-Rom for
|
||
// a sharp result on text, which is what a packet photo is mostly made of.
|
||
func downscale(img image.Image, maxDim int) image.Image {
|
||
b := img.Bounds()
|
||
w, h := b.Dx(), b.Dy()
|
||
longest := w
|
||
if h > longest {
|
||
longest = h
|
||
}
|
||
if longest <= maxDim || longest == 0 {
|
||
return img
|
||
}
|
||
scale := float64(maxDim) / float64(longest)
|
||
nw, nh := max(int(float64(w)*scale), 1), max(int(float64(h)*scale), 1)
|
||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
||
return dst
|
||
}
|