// Package thumbs 懒生成上传图片的缩略图并落盘缓存(memos 模式): // 首次请求时从存储端读一次原图,缩放编码后进缓存,此后不再碰原文件; // 生成失败写 .failed 标记,1 小时内不反复重试。 package thumbs import ( "bytes" "fmt" "image" "image/gif" "image/jpeg" "image/png" "io" "os" "path/filepath" "strings" "sync" "time" ) const ( // MaxSrcBytes 超过此大小的原图不生成(防止大图拖垮请求),调用方回原图。 MaxSrcBytes = 25 << 20 // 25MB // maxDim 单边像素上限,超过视为畸形图(解压炸弹防线)。 maxDim = 10000 // JPEGQuality 转码输出质量。 JPEGQuality = 82 // failedTTL 失败后的重试冷却。 failedTTL = time.Hour ) // Supported 报告该 mime 是否走缩略图管线(标准库能完整解码的静图)。 func Supported(mime string) bool { switch mime { case "image/jpeg", "image/png", "image/gif": return true } return false } // Generate 把原图字节缩放到宽 w(只缩不放)。已够小的原生格式图原样直通 // (返回 passThrough=true,调用方直接落盘 src),其余统一转 JPEG—— // 缩略图不需要 alpha,透明区铺白底。 func Generate(src []byte, mime string, w int) (out []byte, outMime string, passThrough bool, err error) { cfg, _, err := image.DecodeConfig(bytes.NewReader(src)) if err != nil { return nil, "", false, fmt.Errorf("decode config: %w", err) } if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDim || cfg.Height > maxDim { return nil, "", false, fmt.Errorf("bad dimensions %dx%d", cfg.Width, cfg.Height) } if cfg.Width <= w && Supported(mime) { return src, mime, true, nil } var img image.Image switch mime { case "image/jpeg": img, err = jpeg.Decode(bytes.NewReader(src)) case "image/png": img, err = png.Decode(bytes.NewReader(src)) case "image/gif": img, err = gif.Decode(bytes.NewReader(src)) // 首帧,缩略图不做动画 default: return nil, "", false, fmt.Errorf("unsupported mime %q", mime) } if err != nil { return nil, "", false, fmt.Errorf("decode: %w", err) } var buf bytes.Buffer if err := jpeg.Encode(&buf, Resize(img, w), &jpeg.Options{Quality: JPEGQuality}); err != nil { return nil, "", false, err } return buf.Bytes(), "image/jpeg", false, nil } // Resize 盒均值(box-average)缩放到宽 nw(等比,只缩不放)。 // 非预乘 RGBA 按 alpha 加权平均,透明像素不计入颜色、铺白底。纯标准库。 func Resize(src image.Image, nw int) *image.RGBA { b := src.Bounds() ow, oh := b.Dx(), b.Dy() if nw <= 0 || nw >= ow { nw = ow } nh := (oh*nw + ow/2) / ow dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) flat := flatten(src, b) for y := 0; y < nh; y++ { y0 := y * oh / nh y1 := (y+1)*oh/nh + 1 if y1 > oh { y1 = oh } if y1 <= y0 { y1 = y0 + 1 } for x := 0; x < nw; x++ { x0 := x * ow / nw x1 := (x+1)*ow/nw + 1 if x1 > ow { x1 = ow } if x1 <= x0 { x1 = x0 + 1 } var r, g, bl, a, n uint64 for sy := y0; sy < y1; sy++ { row := sy * ow * 4 for sx := x0; sx < x1; sx++ { i := row + sx*4 al := uint64(flat[i+3]) r += uint64(flat[i]) * al g += uint64(flat[i+1]) * al bl += uint64(flat[i+2]) * al a += al n++ } } d := y*nw*4 + x*4 if a == 0 || n == 0 { dst.Pix[d], dst.Pix[d+1], dst.Pix[d+2], dst.Pix[d+3] = 255, 255, 255, 255 continue } dst.Pix[d] = uint8(r / a) dst.Pix[d+1] = uint8(g / a) dst.Pix[d+2] = uint8(bl / a) dst.Pix[d+3] = uint8(a / n) } } return dst } // flatten 把任意 image.Image 转成 w*h 扁平非预乘 RGBA 字节。 func flatten(src image.Image, b image.Rectangle) []byte { w, h := b.Dx(), b.Dy() out := make([]byte, w*h*4) i := 0 for y := b.Min.Y; y < b.Max.Y; y++ { for x := b.Min.X; x < b.Max.X; x++ { r, g, bl, a := src.At(x, y).RGBA() // 0..0xffff,预乘 pa := a >> 8 if pa == 0 { out[i], out[i+1], out[i+2], out[i+3] = 0, 0, 0, 0 } else { out[i] = uint8(min255((r >> 8) * 0xff / pa)) out[i+1] = uint8(min255((g >> 8) * 0xff / pa)) out[i+2] = uint8(min255((bl >> 8) * 0xff / pa)) out[i+3] = uint8(pa) } i += 4 } } return out } func min255(v uint32) uint32 { if v > 0xff { return 0xff } return v } // Store 是缩略图磁盘缓存: // // {Dir}/{sha[:2]}/{sha[:32]}-{w}.jpg 成功产物(直通可能是 .png/.gif) // {Dir}/{sha[:2]}/{sha[:32]}.failed 失败标记 // // 文件名以原图内容哈希(files.sha256)为键:内容变则键变,缓存天然失效。 type Store struct { Dir string mu sync.Mutex locks map[string]*sync.Mutex } func NewStore(dir string) *Store { return &Store{Dir: dir, locks: make(map[string]*sync.Mutex)} } func (s *Store) dir(sha string) string { return filepath.Join(s.Dir, sha[:2]) } func (s *Store) jpegPath(sha string, w int) string { return filepath.Join(s.dir(sha), fmt.Sprintf("%s-%d.jpg", sha[:32], w)) } func (s *Store) failedPath(sha string) string { return filepath.Join(s.dir(sha), sha[:32]+".failed") } // FindCached 返回已存在的缓存文件(jpg 优先,其次直通的 png/gif),没有则空串。 func (s *Store) FindCached(sha string, w int) string { base := strings.TrimSuffix(s.jpegPath(sha, w), ".jpg") for _, ext := range []string{".jpg", ".png", ".gif"} { if fi, err := os.Stat(base + ext); err == nil && !fi.IsDir() { return base + ext } } return "" } // Put 原子写入缓存文件(内容直通时保留原生格式),成功则清掉失败标记。 func (s *Store) Put(sha string, w int, data []byte, mime string) (string, error) { p := s.jpegPath(sha, w) if mime != "image/jpeg" { p = strings.TrimSuffix(p, ".jpg") + extFor(mime) } if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return "", err } tmp := p + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return "", err } if err := os.Rename(tmp, p); err != nil { os.Remove(tmp) return "", err } _ = os.Remove(s.failedPath(sha)) return p, nil } // FailedRecently 报告是否在冷却期(近期生成失败过)。 func (s *Store) FailedRecently(sha string) bool { fi, err := os.Stat(s.failedPath(sha)) return err == nil && time.Since(fi.ModTime()) < failedTTL } // MarkFailed 落失败标记。 func (s *Store) MarkFailed(sha string) { if err := os.MkdirAll(s.dir(sha), 0o755); err == nil { _ = os.WriteFile(s.failedPath(sha), []byte("thumbs: generation failed\n"), 0o644) } } // Lock 取该(哈希, 宽度)的互斥锁:并发首请求只生成一次,其余等待后读缓存。 func (s *Store) Lock(sha string, w int) *sync.Mutex { s.mu.Lock() defer s.mu.Unlock() if len(s.locks) > lockCacheMax { clear(s.locks) // 粗粒度回收:正在持有的锁不受影响(调用方拿着指针), } // 清掉的只是历史条目,最坏情况是同一图并发重生成一次 k := fmt.Sprintf("%s-%d", sha[:32], w) l, ok := s.locks[k] if !ok { l = &sync.Mutex{} s.locks[k] = l } return l } // lockCacheMax 限制锁表大小(每张图最多 3 种宽度),博客体量下足够。 const lockCacheMax = 4096 func extFor(mime string) string { switch mime { case "image/png": return ".png" case "image/gif": return ".gif" } return ".jpg" } // ReadAllLimited 读至多 max 字节,超限报错(防止把 25MB+ 原图吞进内存)。 func ReadAllLimited(r io.Reader, max int64) ([]byte, error) { data, err := io.ReadAll(io.LimitReader(r, max+1)) if err != nil { return nil, err } if int64(len(data)) > max { return nil, fmt.Errorf("source exceeds %d bytes", max) } return data, nil }