// Package media compresses images (stdlib + x/image) and videos (ffmpeg) for bot pipelines. // Deploy note: video compression requires the `ffmpeg` binary on PATH (or Options.FFmpegPath). package media import ( "bytes" "fmt" "image" _ "image/gif" "image/jpeg" _ "image/png" "strings" "golang.org/x/image/draw" 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() Options { 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" } return o } // ImageResult is compressed image output. type ImageResult struct { Data []byte Width int Height int MIME string } // CompressImage scales longest edge to ≤ Limits.ImageMaxEdge and re-encodes as JPEG (PNG in → JPEG out unless already small PNG kept as PNG when no resize needed and source is PNG). func CompressImage(src []byte, opt Options) (*ImageResult, error) { opt = 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 if !pkgmedia.NeedsResize(w, h, maxEdge) && int64(len(src)) <= opt.Limits.ImageMaxBytes { mime := mimeForFormat(format) return &ImageResult{Data: src, Width: w, Height: h, MIME: mime}, nil } nw, nh := scaleToMax(w, h, maxEdge) dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil) var buf bytes.Buffer if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: opt.JPEGQuality}); err != nil { return nil, fmt.Errorf("media: encode jpeg: %w", err) } out := buf.Bytes() if int64(len(out)) > opt.Limits.ImageMaxBytes { return nil, fmt.Errorf("media: compressed image still too large (%d > %d)", len(out), opt.Limits.ImageMaxBytes) } return &ImageResult{Data: out, Width: nw, Height: nh, MIME: "image/jpeg"}, nil } // CompressAvatar scales to ≤ AvatarMaxEdge and JPEG-encodes. func CompressAvatar(src []byte, opt Options) (*ImageResult, error) { opt = opt.normalize() lim := opt.Limits lim.ImageMaxEdge = lim.AvatarMaxEdge lim.ImageMaxBytes = lim.AvatarMaxBytes opt.Limits = lim return CompressImage(src, opt) } 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" 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 }