spark-server/internal/infrastructure/media/image.go
DESKTOP-L8OUL9F\gfl0034 753c276496 feat: PopAdmin 拉取管理、签到能量 API 与图片 150KiB 硬上限
管理端按用户查询开启通讯录/相册/媒体拉取;服务端写入路径自动压图;新增 /v1/sign 与 /v1/energy。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 12:44:06 +08:00

253 lines
6.6 KiB
Go

// 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
}