// Package media compresses images (stdlib + x/image) and videos (ffmpeg) for server pipelines. // All server-written images are capped at Limits.ImageMaxBytes (default 150 KiB). // Deploy note: video compression requires the `ffmpeg` binary on PATH (or Options.FFmpegPath). package media import ( "bytes" "fmt" "image" _ "image/gif" // register GIF decoder "image/jpeg" "image/png" "strings" "golang.org/x/image/draw" _ "golang.org/x/image/webp" // register WebP decoder pkgmedia "spark-server/internal/pkg/media" ) // Options configures compressors. type Options struct { Limits pkgmedia.Limits JPEGQuality int VideoCRF int FFmpegPath string // default "ffmpeg" } func (o *Options) normalize() { o.Limits = o.Limits.Normalize() if o.JPEGQuality <= 0 || o.JPEGQuality > 100 { o.JPEGQuality = pkgmedia.DefaultJPEGQuality } if o.VideoCRF <= 0 { o.VideoCRF = pkgmedia.DefaultVideoCRF } if strings.TrimSpace(o.FFmpegPath) == "" { o.FFmpegPath = "ffmpeg" } } // ImageResult is compressed image output. type ImageResult struct { Data []byte Width int Height int MIME string } var jpegQualitySteps = []int{85, 75, 65, 55, 45, 35, 28, 22} // CompressImage scales longest edge to ≤ Limits.ImageMaxEdge and re-encodes as JPEG, // stepping quality down then scaling further until ≤ ImageMaxBytes (default 150 KiB). // Already-small images under the byte cap (and edge) are returned unchanged. func CompressImage(src []byte, opt *Options) (*ImageResult, error) { if opt == nil { return nil, fmt.Errorf("media: nil options") } opt.normalize() if len(src) == 0 { return nil, fmt.Errorf("media: empty image") } img, format, err := image.Decode(bytes.NewReader(src)) if err != nil { return nil, fmt.Errorf("media: decode image: %w", err) } b := img.Bounds() w, h := b.Dx(), b.Dy() maxEdge := opt.Limits.ImageMaxEdge maxBytes := opt.Limits.ImageMaxBytes if maxBytes <= 0 { maxBytes = pkgmedia.DefaultImageMaxBytes } if !pkgmedia.NeedsResize(w, h, maxEdge) && int64(len(src)) <= maxBytes { return &ImageResult{Data: src, Width: w, Height: h, MIME: mimeForFormat(format)}, nil } nw, nh := scaleToMax(w, h, maxEdge) cur := resample(img, b, nw, nh) qualities := qualityLadder(opt.JPEGQuality) best, bw, bh, err := encodeJPEGLadder(cur, qualities, maxBytes) if err != nil { return nil, err } if int64(len(best)) <= maxBytes { return &ImageResult{Data: best, Width: bw, Height: bh, MIME: "image/jpeg"}, nil } // Still over: shrink edge iteratively down to MinImageEdge. edge := pkgmedia.LongestEdge(bw, bh) minEdge := pkgmedia.MinImageEdge for edge > minEdge && int64(len(best)) > maxBytes { edge = edge * 4 / 5 if edge < minEdge { edge = minEdge } cw, ch := scaleToMax(w, h, edge) cur = resample(img, b, cw, ch) cand, cw2, ch2, encErr := encodeJPEGLadder(cur, []int{55, 45, 35, 28, 22}, maxBytes) if encErr != nil { return nil, encErr } best, bw, bh = cand, cw2, ch2 if int64(len(best)) <= maxBytes { break } } if int64(len(best)) > maxBytes { return nil, fmt.Errorf("media: compressed image still too large (%d > %d)", len(best), maxBytes) } return &ImageResult{Data: best, Width: bw, Height: bh, MIME: "image/jpeg"}, nil } // CompressAvatar scales to ≤ AvatarMaxEdge and JPEG-encodes under AvatarMaxBytes. func CompressAvatar(src []byte, opt *Options) (*ImageResult, error) { if opt == nil { return nil, fmt.Errorf("media: nil options") } opt.normalize() lim := opt.Limits lim.ImageMaxEdge = lim.AvatarMaxEdge lim.ImageMaxBytes = lim.AvatarMaxBytes opt.Limits = lim return CompressImage(src, opt) } // DefaultOptions returns compressor options with 150 KiB image/avatar caps. func DefaultOptions() *Options { return &Options{Limits: pkgmedia.DefaultLimits(), JPEGQuality: pkgmedia.DefaultJPEGQuality} } // IsImageMIME reports whether contentType looks like a raster image we can compress. func IsImageMIME(contentType string) bool { ct := strings.ToLower(strings.TrimSpace(contentType)) ct, _, _ = strings.Cut(ct, ";") ct = strings.TrimSpace(ct) switch ct { case "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/bmp", "image/x-png": return true default: return false } } // EnsureUnderLimit compresses image bytes when over ImageMaxBytes / max edge. // Non-image content types are returned unchanged. On compress failure returns an error. func EnsureUnderLimit(src []byte, contentType string, opt *Options) (data []byte, mime string, err error) { if !IsImageMIME(contentType) { return src, contentType, nil } if opt == nil { opt = DefaultOptions() } res, err := CompressImage(src, opt) if err != nil { return nil, "", err } return res.Data, res.MIME, nil } func qualityLadder(start int) []int { if start <= 0 || start > 100 { start = pkgmedia.DefaultJPEGQuality } out := make([]int, 0, len(jpegQualitySteps)+1) seen := map[int]struct{}{} add := func(q int) { if q < 20 { q = 20 } if q > 100 { q = 100 } if _, ok := seen[q]; ok { return } seen[q] = struct{}{} out = append(out, q) } add(start) for _, q := range jpegQualitySteps { if q < start { add(q) } } return out } func encodeJPEGLadder(img image.Image, qualities []int, maxBytes int64) ([]byte, int, int, error) { b := img.Bounds() w, h := b.Dx(), b.Dy() var best []byte for _, q := range qualities { var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil { return nil, 0, 0, fmt.Errorf("media: encode jpeg: %w", err) } out := buf.Bytes() best = out if int64(len(out)) <= maxBytes { return out, w, h, nil } } if best == nil { return nil, 0, 0, fmt.Errorf("media: jpeg encode empty") } return best, w, h, nil } func resample(src image.Image, srcBounds image.Rectangle, nw, nh int) *image.RGBA { dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) draw.CatmullRom.Scale(dst, dst.Bounds(), src, srcBounds, draw.Over, nil) return dst } func scaleToMax(w, h, maxEdge int) (int, int) { if maxEdge <= 0 || !pkgmedia.NeedsResize(w, h, maxEdge) { return w, h } edge := pkgmedia.LongestEdge(w, h) nw := w * maxEdge / edge nh := h * maxEdge / edge if nw < 1 { nw = 1 } if nh < 1 { nh = 1 } return nw, nh } func mimeForFormat(format string) string { switch strings.ToLower(format) { case "png": return "image/png" case "gif": return "image/gif" case "webp": return "image/webp" default: return "image/jpeg" } } // EncodePNG is a test helper / bot helper for synthetic images. func EncodePNG(img image.Image) ([]byte, error) { var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { return nil, err } return buf.Bytes(), nil }