package imgutil import ( "bytes" "crypto/sha1" "fmt" "image" _ "image/gif" "image/jpeg" _ "image/png" "net/url" "path" "path/filepath" "strings" "unicode" ) const ( // CoverResizeLimitKB 与 media utils.Resize 一致:超过该大小才缩放 CoverResizeLimitKB = 60 // CoverMaxWidth 与 media Thumbnail 一致 CoverMaxWidth = 750 // CoverMaxHeight 与 media Thumbnail 一致 CoverMaxHeight = 1560 // CoverJPEGQuality 压缩后统一输出 JPEG CoverJPEGQuality = 75 ) // IsGIF 按文件头判断是否为 GIF,不依赖扩展名。 func IsGIF(data []byte) bool { return len(data) >= 3 && bytes.Equal(data[:3], []byte{0x47, 0x49, 0x46}) } // NeedCompress 原图是否超过 media Resize 阈值,未超过则无需压缩重传。 // GIF 保持原格式和动效,不参与压缩。 func NeedCompress(data []byte) bool { if IsGIF(data) { return false } return int64(len(data))/1024 > CoverResizeLimitKB } // CompressCover 按 media Resize(60KB, 750×1560) 压缩封面。 // GIF 原样返回;其它格式未超过阈值时原样返回,超过则缩放后统一输出 JPEG。 func CompressCover(data []byte, fileName string) ([]byte, string, error) { if len(data) == 0 { return nil, "", fmt.Errorf("图片内容为空") } outName := compressFileName(fileName) if IsGIF(data) { return data, withExt(outName, ".gif"), nil } if !NeedCompress(data) { return data, outName, nil } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, "", fmt.Errorf("解码图片失败: %w", err) } thumb := thumbnail(img, CoverMaxWidth, CoverMaxHeight) var buf bytes.Buffer if err = jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: CoverJPEGQuality}); err != nil { return nil, "", err } return buf.Bytes(), withExt(outName, ".jpg"), nil } // SafeCoverFileName 生成仅含 ASCII 的上传文件名。 // 原封面路径含中文时,直接用 path.Base 会把中文带进上传名,图床返回地址会异常。 func SafeCoverFileName(src string) string { src = strings.TrimSpace(src) ext := asciiImageExt(src) if src == "" { return "playlet_cover" + ext } sum := sha1.Sum([]byte(src)) return fmt.Sprintf("playlet_cover_%x%s", sum[:8], ext) } func thumbnail(img image.Image, maxW, maxH int) image.Image { b := img.Bounds() w, h := b.Dx(), b.Dy() if w <= maxW && h <= maxH { return img } wr := float64(maxW) / float64(w) hr := float64(maxH) / float64(h) scale := wr if hr < wr { scale = hr } nw := int(float64(w) * scale) nh := int(float64(h) * scale) if nw < 1 { nw = 1 } if nh < 1 { nh = 1 } dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) for y := 0; y < nh; y++ { sy := b.Min.Y + y*h/nh for x := 0; x < nw; x++ { sx := b.Min.X + x*w/nw dst.Set(x, y, img.At(sx, sy)) } } return dst } func compressFileName(fileName string) string { fileName = filepath.Base(strings.TrimSpace(fileName)) if fileName == "" || fileName == "." || fileName == string(filepath.Separator) || !isASCIIFileName(fileName) { return "playlet_cover.jpg" } return fileName } func asciiImageExt(src string) string { name := src if u, err := url.Parse(src); err == nil && u.Path != "" { name = u.Path } name = strings.Split(path.Base(name), "?")[0] if dec, err := url.PathUnescape(name); err == nil { name = dec } ext := strings.ToLower(filepath.Ext(name)) switch ext { case ".jpg", ".jpeg", ".png", ".gif", ".webp": return ext default: return ".jpg" } } func isASCIIFileName(name string) bool { if !strings.Contains(name, ".") { return false } for _, r := range name { if r > unicode.MaxASCII || r < 32 || r == '"' || r == '/' || r == '\\' { return false } } return true } func withExt(fileName, ext string) string { return strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ext }