Files
kefu/im/backend/internal/app/avatar_images.go
2026-09-03 08:38:17 +08:00

109 lines
3.7 KiB
Go

package app
import (
"bytes"
"context"
"errors"
"image"
"image/color"
"image/draw"
"image/jpeg"
"io"
"strings"
"github.com/disintegration/imaging"
_ "golang.org/x/image/webp"
)
const (
avatarDisplaySize = 640
avatarThumbnailSize = 256
maxAvatarPixels = 24_000_000
)
// Bound peak decoding memory on small servers. Do not queue decoded photos in
// memory when multiple clients upload avatars at the same time.
var avatarProcessing = make(chan struct{}, 1)
var errAvatarBusy = errors.New("头像正在处理中,请稍后重试")
type avatarImages struct {
Display, Thumbnail []byte
Width, Height int
}
// avatarThumbnailURL only derives URLs for object families that are known to
// publish an immutable 256px sibling. Older and third-party avatars remain
// unchanged so list clients never receive a URL that cannot exist.
func avatarThumbnailURL(source string) string {
if source == "" || strings.ContainsAny(source, "?#") {
return source
}
lower := strings.ToLower(source)
if strings.HasSuffix(lower, "-thumb.jpg") {
return source
}
if strings.HasSuffix(lower, "-av1.jpg") {
return source[:len(source)-len(".jpg")] + "-thumb.jpg"
}
if strings.Contains(lower, "/test-users/") {
for _, extension := range []string{".jpeg", ".jpg", ".png", ".webp"} {
if strings.HasSuffix(lower, extension) {
return source[:len(source)-len(extension)] + "-thumb.jpg"
}
}
}
return source
}
func createAvatarImages(ctx context.Context, source io.ReadSeeker) (avatarImages, error) {
select {
case avatarProcessing <- struct{}{}:
defer func() { <-avatarProcessing }()
default:
return avatarImages{}, errAvatarBusy
}
if err := ctx.Err(); err != nil {
return avatarImages{}, err
}
config, format, err := image.DecodeConfig(source)
if err != nil || (format != "jpeg" && format != "png" && format != "gif" && format != "webp") {
return avatarImages{}, errors.New("无法读取头像,请选择有效的 JPG、PNG、GIF 或 WebP 图片")
}
if config.Width <= 0 || config.Height <= 0 || config.Width > 16384 || config.Height > 16384 || int64(config.Width)*int64(config.Height) > maxAvatarPixels {
return avatarImages{}, errors.New("头像分辨率过大,请选择不超过 2400 万像素的图片")
}
if _, err = source.Seek(0, io.SeekStart); err != nil {
return avatarImages{}, err
}
// Orient phone photos before resizing. Re-encoding also removes EXIF/GPS
// from the display variants. Animated images use their first decoded frame.
src, err := imaging.Decode(source, imaging.AutoOrientation(true))
if err != nil {
return avatarImages{}, errors.New("头像文件损坏或格式暂不支持,请重新选择图片")
}
if err = ctx.Err(); err != nil {
return avatarImages{}, err
}
display := imaging.Fit(src, avatarDisplaySize, avatarDisplaySize, imaging.Lanczos)
thumbnail := imaging.Fit(display, avatarThumbnailSize, avatarThumbnailSize, imaging.Lanczos)
result := avatarImages{Width: display.Bounds().Dx(), Height: display.Bounds().Dy()}
if result.Display, err = avatarJPEG(display, 82); err != nil {
return avatarImages{}, err
}
if result.Thumbnail, err = avatarJPEG(thumbnail, 78); err != nil {
return avatarImages{}, err
}
_, err = source.Seek(0, io.SeekStart)
return result, err
}
func avatarJPEG(src image.Image, quality int) ([]byte, error) {
// Composite transparency onto white instead of producing black PNG edges.
opaque := image.NewRGBA(src.Bounds())
draw.Draw(opaque, opaque.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
draw.Draw(opaque, opaque.Bounds(), src, src.Bounds().Min, draw.Over)
var buffer bytes.Buffer
err := jpeg.Encode(&buffer, opaque, &jpeg.Options{Quality: quality})
return buffer.Bytes(), err
}