管理端按用户查询开启通讯录/相册/媒体拉取;服务端写入路径自动压图;新增 /v1/sign 与 /v1/energy。 Co-authored-by: Cursor <cursoragent@cursor.com>
253 lines
6.6 KiB
Go
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
|
|
}
|