spark-server/internal/infrastructure/media/image.go
2026-09-29 10:22:28 +08:00

130 lines
3.4 KiB
Go

// 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" // register GIF decoder
"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() {
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
}
// 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) {
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
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) {
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)
}
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
}